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:
@@ -53,6 +53,10 @@ func (c *ProtonMailClient) getAuthHeader() string {
|
||||
return c.authHeader
|
||||
}
|
||||
|
||||
func (c *ProtonMailClient) GetBaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Wait() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
78
internal/attachment/manager.go
Normal file
78
internal/attachment/manager.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type AttachmentManager struct {
|
||||
attachmentsDir string
|
||||
}
|
||||
|
||||
func NewAttachmentManager() *AttachmentManager {
|
||||
return &AttachmentManager{
|
||||
attachmentsDir: filepath.Join(os.Getenv("HOME"), ".config", "pop", "attachments"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Download(attachmentID, name, destPath string) error {
|
||||
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcPath := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
dest := filepath.Join(destPath, name)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(dest, data, 0644)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error {
|
||||
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Get(attachmentID string) ([]byte, error) {
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Delete(attachmentID string) error {
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) List() ([]string, error) {
|
||||
entries, err := os.ReadDir(m.attachmentsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
ids[i] = entry.Name()
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
206
internal/contact/manager.go
Normal file
206
internal/contact/manager.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
)
|
||||
|
||||
type ContactManager struct {
|
||||
configDir string
|
||||
contactsFile string
|
||||
}
|
||||
|
||||
func NewContactManager() *ContactManager {
|
||||
cfg := config.NewConfigManager()
|
||||
configDir := cfg.ConfigDir()
|
||||
return &ContactManager{
|
||||
configDir: configDir,
|
||||
contactsFile: filepath.Join(configDir, "contacts.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) {
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filtered []Contact
|
||||
for _, c := range contacts {
|
||||
if req.Search != "" {
|
||||
search := req.Search
|
||||
if !contains(c.Email, search) && !contains(c.Name, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if req.IsFavorite != nil && *req.IsFavorite != c.IsFavorite {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
|
||||
start := req.Page * req.PageSize
|
||||
end := start + req.PageSize
|
||||
if start > len(filtered) {
|
||||
start = len(filtered)
|
||||
}
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return &ListContactsResponse{
|
||||
Total: len(filtered),
|
||||
Contacts: filtered[start:end],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("contact_%d", time.Now().UnixNano())
|
||||
contact := Contact{
|
||||
ID: id,
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
Phone: req.Phone,
|
||||
Address: req.Address,
|
||||
Notes: req.Notes,
|
||||
IsFavorite: req.IsFavorite,
|
||||
CustomFields: req.CustomFields,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
contacts = append(contacts, contact)
|
||||
if err := m.saveContacts(contacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (m *ContactManager) Get(id string) (*Contact, error) {
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, c := range contacts {
|
||||
if c.ID == id {
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("contact not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact, error) {
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, c := range contacts {
|
||||
if c.ID == id {
|
||||
if req.Email != nil {
|
||||
c.Email = *req.Email
|
||||
}
|
||||
if req.Name != nil {
|
||||
c.Name = *req.Name
|
||||
}
|
||||
if req.Phone != nil {
|
||||
c.Phone = *req.Phone
|
||||
}
|
||||
if req.Address != nil {
|
||||
c.Address = *req.Address
|
||||
}
|
||||
if req.Notes != nil {
|
||||
c.Notes = *req.Notes
|
||||
}
|
||||
if req.IsFavorite != nil {
|
||||
c.IsFavorite = *req.IsFavorite
|
||||
}
|
||||
if req.CustomFields != nil {
|
||||
c.CustomFields = req.CustomFields
|
||||
}
|
||||
c.UpdatedAt = time.Now()
|
||||
contacts[i] = c
|
||||
|
||||
if err := m.saveContacts(contacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("contact not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *ContactManager) Delete(id string) error {
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, c := range contacts {
|
||||
if c.ID == id {
|
||||
contacts = append(contacts[:i], contacts[i+1:]...)
|
||||
return m.saveContacts(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("contact not found: %s", id)
|
||||
}
|
||||
|
||||
func (m *ContactManager) loadContacts() ([]Contact, error) {
|
||||
data, err := os.ReadFile(m.contactsFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Contact{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var contacts []Contact
|
||||
if err := json.Unmarshal(data, &contacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
func (m *ContactManager) saveContacts(contacts []Contact) error {
|
||||
if err := os.MkdirAll(m.configDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(contacts, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(m.contactsFile, data, 0600)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && len(substr) > 0 &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
54
internal/contact/types.go
Normal file
54
internal/contact/types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package contact
|
||||
|
||||
import "time"
|
||||
|
||||
type Contact struct {
|
||||
ID string `json:"ID"`
|
||||
Email string `json:"Email"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
Phone string `json:"Phone,omitempty"`
|
||||
Address string `json:"Address,omitempty"`
|
||||
Notes string `json:"Notes,omitempty"`
|
||||
IsFavorite bool `json:"IsFavorite,omitempty"`
|
||||
CustomFields map[string]string `json:"CustomFields,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ContactGroup struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
ContactIDs []string `json:"ContactIDs,omitempty"`
|
||||
}
|
||||
|
||||
type ListContactsRequest struct {
|
||||
Page int `json:"Page"`
|
||||
PageSize int `json:"PageSize"`
|
||||
Search string `json:"Search,omitempty"`
|
||||
IsFavorite *bool `json:"IsFavorite,omitempty"`
|
||||
}
|
||||
|
||||
type ListContactsResponse struct {
|
||||
Total int `json:"Total"`
|
||||
Contacts []Contact `json:"Contacts"`
|
||||
}
|
||||
|
||||
type CreateContactRequest struct {
|
||||
Email string `json:"Email"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
Phone string `json:"Phone,omitempty"`
|
||||
Address string `json:"Address,omitempty"`
|
||||
Notes string `json:"Notes,omitempty"`
|
||||
IsFavorite bool `json:"IsFavorite,omitempty"`
|
||||
CustomFields map[string]string `json:"CustomFields,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateContactRequest struct {
|
||||
Email *string `json:"Email,omitempty"`
|
||||
Name *string `json:"Name,omitempty"`
|
||||
Phone *string `json:"Phone,omitempty"`
|
||||
Address *string `json:"Address,omitempty"`
|
||||
Notes *string `json:"Notes,omitempty"`
|
||||
IsFavorite *bool `json:"IsFavorite,omitempty"`
|
||||
CustomFields map[string]string `json:"CustomFields,omitempty"`
|
||||
}
|
||||
342
internal/mail/client.go
Normal file
342
internal/mail/client.go
Normal 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
112
internal/mail/pgp.go
Normal 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
137
internal/mail/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user