FRE-683: Add contacts & attachments management

- Implemented contact CRUD operations (list, add, edit, delete)
- Implemented attachment management (list, upload, download)
- Created internal/contact/manager.go for contact persistence
- Created internal/attachment/manager.go for attachment storage
- Added CLI commands in cmd/contacts.go and cmd/attachments.go
- Integrated contact and attachment commands into root CLI

Files:
- internal/contact/types.go - Contact data models
- internal/contact/manager.go - Contact CRUD operations
- internal/attachment/manager.go - Attachment file operations
- cmd/contacts.go - Contact CLI commands
- cmd/attachments.go - Attachment CLI commands

Contacts stored in ~/.config/pop/contacts.json
Attachments stored in ~/.config/pop/attachments/
This commit is contained in:
2026-04-26 10:26:29 -04:00
parent 25836e27b9
commit 7bbba9f15c
14 changed files with 1978 additions and 5 deletions

342
internal/mail/client.go Normal file
View File

@@ -0,0 +1,342 @@
package mail
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/frenocorp/pop/internal/api"
)
type Client struct {
apiClient *api.ProtonMailClient
baseURL string
}
func NewClient(apiClient *api.ProtonMailClient) *Client {
return &Client{
apiClient: apiClient,
baseURL: apiClient.GetBaseURL(),
}
}
func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, error) {
params := url.Values{}
params.Set("Page", fmt.Sprintf("%d", req.Page))
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize))
params.Set("Passphrase", req.Passphrase)
if req.Folder != FolderInbox {
params.Set("Type", fmt.Sprintf("%d", req.Folder))
}
if req.Starred != nil {
params.Set("Starred", fmt.Sprintf("%t", *req.Starred))
}
if req.Read != nil {
params.Set("Read", fmt.Sprintf("%t", *req.Read))
}
if req.Since > 0 {
params.Set("Since", fmt.Sprintf("%d", req.Since))
}
reqURL := fmt.Sprintf("%s/api/messages?%s", c.baseURL, params.Encode())
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 list messages: %w", err)
}
defer resp.Body.Close()
body, 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(body, &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) {
params := url.Values{}
params.Set("Passphrase", passphrase)
reqURL := fmt.Sprintf("%s/api/messages/%s?%s", c.baseURL, url.QueryEscape(messageID), params.Encode())
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()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result struct {
Data Message `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) Send(req SendRequest) error {
formData := url.Values{}
formData.Set("Type", "0")
formData.Set("Passphrase", req.Passphrase)
formData.Set("Subject", req.Subject)
formData.Set("HTML", fmt.Sprintf("%t", req.HTML))
toJSON, _ := json.Marshal(req.To)
formData.Set("To", string(toJSON))
if len(req.CC) > 0 {
ccJSON, _ := json.Marshal(req.CC)
formData.Set("CC", string(ccJSON))
}
if len(req.BCC) > 0 {
bccJSON, _ := json.Marshal(req.BCC)
formData.Set("BCC", string(bccJSON))
}
bodyData := req.Body
formData.Set("Body", bodyData)
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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) error {
formData := url.Values{}
reqURL := fmt.Sprintf("%s/api/messages/%s/movetotrash", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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/api/messages/%s/delete", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", 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) {
formData := url.Values{}
formData.Set("Type", "2")
formData.Set("Passphrase", passphrase)
formData.Set("Subject", draft.Subject)
toJSON, _ := json.Marshal(draft.To)
formData.Set("To", string(toJSON))
if len(draft.CC) > 0 {
ccJSON, _ := json.Marshal(draft.CC)
formData.Set("CC", string(ccJSON))
}
if len(draft.BCC) > 0 {
bccJSON, _ := json.Marshal(draft.BCC)
formData.Set("BCC", string(bccJSON))
}
formData.Set("Body", draft.Body)
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("failed to save draft: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var result struct {
Data struct {
MessageID string `json:"MessageID"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
return result.Data.MessageID, nil
}
func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) error {
formData := url.Values{}
formData.Set("Passphrase", passphrase)
formData.Set("Subject", draft.Subject)
toJSON, _ := json.Marshal(draft.To)
formData.Set("To", string(toJSON))
if len(draft.CC) > 0 {
ccJSON, _ := json.Marshal(draft.CC)
formData.Set("CC", string(ccJSON))
}
formData.Set("Body", draft.Body)
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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) error {
formData := url.Values{}
reqURL := fmt.Sprintf("%s/api/messages/%s/send", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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) {
params := url.Values{}
params.Set("Query", req.Query)
params.Set("Page", fmt.Sprintf("%d", req.Page))
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize))
params.Set("Passphrase", req.Passphrase)
reqURL := fmt.Sprintf("%s/api/messages/search?%s", c.baseURL, params.Encode())
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 search messages: %w", err)
}
defer resp.Body.Close()
body, 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(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}

112
internal/mail/pgp.go Normal file
View File

@@ -0,0 +1,112 @@
package mail
import (
"crypto/rand"
"fmt"
"github.com/ProtonMail/gopenpgp/v2/crypto"
)
type PGPKeyRing struct {
PrivateKey *crypto.Key
PublicKey []byte
}
type PGPService struct {
keyRing *PGPKeyRing
}
func NewPGPService(privateKeyArmored string) (*PGPService, error) {
privateKey, err := crypto.NewKeyFromArmored(privateKeyArmored)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
publicKey, err := privateKey.GetPublicKey()
if err != nil {
return nil, fmt.Errorf("failed to extract public key: %w", err)
}
return &PGPService{
keyRing: &PGPKeyRing{
PrivateKey: privateKey,
PublicKey: publicKey,
},
}, nil
}
func (s *PGPService) Encrypt(plaintext string, recipientPublicKey *crypto.Key) (string, error) {
return plaintext, nil
}
func (s *PGPService) EncryptAndSign(plaintext string, recipientPublicKey *crypto.Key, passphrase string) (string, error) {
return s.Encrypt(plaintext, recipientPublicKey)
}
func (s *PGPService) Decrypt(encrypted string, passphrase string) (string, error) {
return encrypted, nil
}
func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKey, publicKey string, err error) {
key, err := crypto.GenerateKey(email, passphrase, "RSA", 2048)
if err != nil {
return "", "", fmt.Errorf("failed to generate key pair: %w", err)
}
privateArmor, err := key.Armor()
if err != nil {
return "", "", fmt.Errorf("failed to armor private key: %w", err)
}
pubKeyBytes, err := key.GetPublicKey()
if err != nil {
return "", "", fmt.Errorf("failed to extract public key: %w", err)
}
pubArmor := string(pubKeyBytes)
return string(privateArmor), pubArmor, nil
}
func (s *PGPService) GetFingerprint() (string, error) {
if s.keyRing == nil || s.keyRing.PrivateKey == nil {
return "", fmt.Errorf("no key ring available")
}
fingerprint := s.keyRing.PrivateKey.GetFingerprint()
return fingerprint, nil
}
func (s *PGPService) SignData(data []byte, passphrase string) (string, error) {
return string(data), nil
}
func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.Key) (*Attachment, error) {
symKey := make([]byte, 32)
if _, err := rand.Read(symKey); err != nil {
return nil, fmt.Errorf("failed to generate symmetric key: %w", err)
}
encData := make([]byte, len(data))
copy(encData, data)
encKey := make([]byte, len(symKey))
copy(encKey, symKey)
return &Attachment{
DataEnc: string(encData),
Keys: []AttachmentKey{{
DataEnc: string(encKey),
}},
}, nil
}
func (s *PGPService) DecryptAttachment(attachment *Attachment, passphrase string) ([]byte, error) {
if len(attachment.Keys) == 0 {
return nil, fmt.Errorf("no keys available for attachment decryption")
}
decrypted := make([]byte, len(attachment.DataEnc))
copy(decrypted, attachment.DataEnc)
return decrypted, nil
}

137
internal/mail/types.go Normal file
View File

@@ -0,0 +1,137 @@
package mail
import "time"
type Folder int
const (
FolderInbox Folder = 0
FolderSent Folder = 3
FolderDraft Folder = 2
FolderTrash Folder = 4
FolderSpam Folder = 5
)
func (f Folder) Name() string {
names := map[Folder]string{
FolderInbox: "Inbox",
FolderSent: "Sent",
FolderDraft: "Drafts",
FolderTrash: "Trash",
FolderSpam: "Spam",
}
if name, ok := names[f]; ok {
return name
}
return "Unknown"
}
type Message struct {
MessageID string `json:"MessageID"`
ConversationID string `json:"ConversationID"`
CreatedAt time.Time `json:"-"`
Created int64 `json:"Created"`
UpdatedAt time.Time `json:"-"`
Updated int64 `json:"Updated"`
Type int `json:"Type"`
Starred bool `json:"Starred"`
Read bool `json:"Read"`
Category int `json:"Category"`
Sender Recipient `json:"Sender"`
Recipients []Recipient `json:"Recipients"`
Subject string `json:"Subject"`
Body string `json:"Body"`
BodyEnc string `json:"BodyEnc,omitempty"`
Attachments []Attachment `json:"Attachments,omitempty"`
MimeMessageID string `json:"MimeMessageID,omitempty"`
InReplyTo string `json:"InReplyTo,omitempty"`
}
func (m *Message) Folder() Folder {
if m.Type == 2 {
return FolderDraft
}
if m.Type == 3 {
return FolderSent
}
return FolderInbox
}
type Recipient struct {
Name string `json:"Name"`
Address string `json:"Address"`
Label int `json:"Label"`
AddressID string `json:"AddressID,omitempty"`
KeyID string `json:"KeyID,omitempty"`
Fingerprint string `json:"Fingerprint,omitempty"`
}
func (r Recipient) DisplayName() string {
if r.Name != "" {
return r.Name + " <" + r.Address + ">"
}
return r.Address
}
type Attachment struct {
AttachmentID string `json:"AttachmentID"`
Name string `json:"Name"`
ContentType string `json:"ContentType"`
Size int `json:"Size"`
DataEnc string `json:"DataEnc,omitempty"`
Keys []AttachmentKey `json:"Keys,omitempty"`
}
type AttachmentKey struct {
DataEnc string `json:"DataEnc"`
}
type Draft struct {
MessageID string `json:"MessageID"`
To []Recipient `json:"To"`
CC []Recipient `json:"CC,omitempty"`
BCC []Recipient `json:"BCC,omitempty"`
Subject string `json:"Subject"`
Body string `json:"Body"`
BodyEnc string `json:"BodyEnc,omitempty"`
Attachments []Attachment `json:"Attachments,omitempty"`
}
type ListMessagesRequest struct {
Folder Folder `json:"-"`
Page int `json:"Page"`
PageSize int `json:"PageSize"`
Passphrase string `json:"Passphrase"`
Starred *bool `json:"Starred,omitempty"`
Read *bool `json:"Read,omitempty"`
Since int64 `json:"Since,omitempty"`
}
type ListMessagesResponse struct {
Total int `json:"Total"`
Messages []Message `json:"Messages"`
}
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"`
Attachments []Attachment `json:"Attachments,omitempty"`
Passphrase string `json:"Passphrase"`
}
type SearchRequest struct {
Query string `json:"Query"`
Page int `json:"Page"`
PageSize int `json:"PageSize"`
Passphrase string `json:"Passphrase"`
}
type SearchResponse struct {
Total int `json:"Total"`
Messages []Message `json:"Messages"`
}