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:
124
cmd/attachments.go
Normal file
124
cmd/attachments.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/attachment"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func attachmentCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "attachment",
|
||||||
|
Short: "Manage attachments",
|
||||||
|
Long: `List, download, and upload attachments.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(attachmentListCmd())
|
||||||
|
cmd.AddCommand(attachmentDownloadCmd())
|
||||||
|
cmd.AddCommand(attachmentUploadCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentListCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all attachments",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
manager := attachment.NewAttachmentManager()
|
||||||
|
|
||||||
|
ids, err := manager.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list attachments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
fmt.Println("No attachments found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Attachments:")
|
||||||
|
for _, id := range ids {
|
||||||
|
fmt.Printf(" - %s\n", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentDownloadCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "download <attachment-id> [output-dir]",
|
||||||
|
Short: "Download an attachment",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("attachment ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := args[0]
|
||||||
|
outputDir := "."
|
||||||
|
if len(args) > 1 {
|
||||||
|
outputDir = args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := attachment.NewAttachmentManager()
|
||||||
|
|
||||||
|
data, err := manager.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.Download(id, id, outputDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to download attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Downloaded attachment %s to %s\n", id, outputDir)
|
||||||
|
fmt.Printf("Size: %d bytes\n", len(data))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentUploadCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "upload <attachment-id> <file-path>",
|
||||||
|
Short: "Upload an attachment",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("attachment ID and file path are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := args[0]
|
||||||
|
filePath := args[1]
|
||||||
|
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
manager := attachment.NewAttachmentManager()
|
||||||
|
|
||||||
|
if err := manager.Upload(id, file.Name(), file); err != nil {
|
||||||
|
return fmt.Errorf("failed to upload attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, _ := file.Stat()
|
||||||
|
fmt.Printf("Uploaded attachment %s (%s, %d bytes)\n", id, file.Name(), info.Size())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(r io.Reader) ([]byte, error) {
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
174
cmd/contacts.go
Normal file
174
cmd/contacts.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/contact"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contactCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "contact",
|
||||||
|
Short: "Manage contacts",
|
||||||
|
Long: `List, add, edit, and delete contacts.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(contactListCmd())
|
||||||
|
cmd.AddCommand(contactAddCmd())
|
||||||
|
cmd.AddCommand(contactEditCmd())
|
||||||
|
cmd.AddCommand(contactDeleteCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactListCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all contacts",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
manager := contact.NewContactManager()
|
||||||
|
|
||||||
|
page, _ := cmd.Flags().GetInt("page")
|
||||||
|
pageSize, _ := cmd.Flags().GetInt("page-size")
|
||||||
|
search, _ := cmd.Flags().GetString("search")
|
||||||
|
|
||||||
|
req := contact.ListContactsRequest{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Search: search,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := manager.List(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list contacts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Contacts) == 0 {
|
||||||
|
fmt.Println("No contacts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(resp.Contacts, "", " ")
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().IntP("page", "p", 0, "Page number")
|
||||||
|
cmd.Flags().IntP("page-size", "n", 10, "Items per page")
|
||||||
|
cmd.Flags().StringP("search", "s", "", "Search by email or name")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactAddCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a new contact",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
email, _ := cmd.Flags().GetString("email")
|
||||||
|
name, _ := cmd.Flags().GetString("name")
|
||||||
|
phone, _ := cmd.Flags().GetString("phone")
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
return fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := contact.NewContactManager()
|
||||||
|
req := contact.CreateContactRequest{
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
Phone: phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
contact, err := manager.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create contact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(contact, "", " ")
|
||||||
|
fmt.Printf("Created contact:\n%s\n", string(data))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("email", "e", "", "Contact email (required)")
|
||||||
|
cmd.Flags().StringP("name", "n", "", "Contact name")
|
||||||
|
cmd.Flags().StringP("phone", "p", "", "Contact phone")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactEditCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "edit <id>",
|
||||||
|
Short: "Edit a contact",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("contact ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := args[0]
|
||||||
|
manager := contact.NewContactManager()
|
||||||
|
|
||||||
|
_, err := manager.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get contact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _ := cmd.Flags().GetString("name")
|
||||||
|
phone, _ := cmd.Flags().GetString("phone")
|
||||||
|
address, _ := cmd.Flags().GetString("address")
|
||||||
|
notes, _ := cmd.Flags().GetString("notes")
|
||||||
|
|
||||||
|
req := contact.UpdateContactRequest{
|
||||||
|
Name: &name,
|
||||||
|
Phone: &phone,
|
||||||
|
Address: &address,
|
||||||
|
Notes: ¬es,
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := manager.Update(id, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update contact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(updated, "", " ")
|
||||||
|
fmt.Printf("Updated contact:\n%s\n", string(data))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("name", "n", "", "New name")
|
||||||
|
cmd.Flags().StringP("phone", "p", "", "New phone")
|
||||||
|
cmd.Flags().StringP("address", "a", "", "New address")
|
||||||
|
cmd.Flags().StringP("notes", "o", "", "New notes")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactDeleteCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a contact",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("contact ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := args[0]
|
||||||
|
manager := contact.NewContactManager()
|
||||||
|
|
||||||
|
if err := manager.Delete(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete contact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deleted contact: %s\n", id)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
261
cmd/draft.go
Normal file
261
cmd/draft.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/api"
|
||||||
|
"github.com/frenocorp/pop/internal/auth"
|
||||||
|
"github.com/frenocorp/pop/internal/config"
|
||||||
|
"github.com/frenocorp/pop/internal/mail"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mailDraftCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "draft",
|
||||||
|
Short: "Manage draft messages",
|
||||||
|
Long: `Save, list, edit, and send draft messages.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(draftSaveCmd())
|
||||||
|
cmd.AddCommand(draftListCmd())
|
||||||
|
cmd.AddCommand(draftEditCmd())
|
||||||
|
cmd.AddCommand(draftSendCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftSaveCmd() *cobra.Command {
|
||||||
|
var to, cc, bcc, subject, bodyFile, body string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "save",
|
||||||
|
Short: "Save a draft message",
|
||||||
|
Long: `Save a message as a draft in ProtonMail.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("recipient is required (--to)")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBody := body
|
||||||
|
if bodyFile != "" {
|
||||||
|
data, err := os.ReadFile(bodyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read body file: %w", err)
|
||||||
|
}
|
||||||
|
msgBody = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients := parseRecipients(to)
|
||||||
|
var ccRecipients []mail.Recipient
|
||||||
|
if cc != "" {
|
||||||
|
ccRecipients = parseRecipients(cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bccRecipients []mail.Recipient
|
||||||
|
if bcc != "" {
|
||||||
|
bccRecipients = parseRecipients(bcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
draft := mail.Draft{
|
||||||
|
To: recipients,
|
||||||
|
CC: ccRecipients,
|
||||||
|
BCC: bccRecipients,
|
||||||
|
Subject: subject,
|
||||||
|
Body: msgBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID, err := mailClient.SaveDraft(draft, session.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save draft: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Draft saved with ID: %s\n", messageID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&to, "to", "t", "", "Recipient addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&cc, "cc", "c", "", "CC addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&bcc, "bcc", "b", "", "BCC addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Draft subject")
|
||||||
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing draft body")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "Inline draft body")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftListCmd() *cobra.Command {
|
||||||
|
var page, pageSize string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List draft messages",
|
||||||
|
Long: `List all draft messages in ProtonMail.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
pageVal, err := strconv.Atoi(page)
|
||||||
|
if err != nil || pageVal < 1 {
|
||||||
|
pageVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSizeVal, err := strconv.Atoi(pageSize)
|
||||||
|
if err != nil || pageSizeVal < 1 {
|
||||||
|
pageSizeVal = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.ListDrafts(pageVal, pageSizeVal, session.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list drafts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return printMessages(result.Messages)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&page, "page", "1", "Page number")
|
||||||
|
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Drafts per page (max 100)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftEditCmd() *cobra.Command {
|
||||||
|
var to, cc, subject, bodyFile, body string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "edit <draft-id>",
|
||||||
|
Short: "Edit a draft message",
|
||||||
|
Long: `Edit an existing draft message in ProtonMail.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
var recipients []mail.Recipient
|
||||||
|
if to != "" {
|
||||||
|
recipients = parseRecipients(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccRecipients []mail.Recipient
|
||||||
|
if cc != "" {
|
||||||
|
ccRecipients = parseRecipients(cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBody := body
|
||||||
|
if bodyFile != "" {
|
||||||
|
data, err := os.ReadFile(bodyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read body file: %w", err)
|
||||||
|
}
|
||||||
|
msgBody = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
draft := mail.Draft{
|
||||||
|
To: recipients,
|
||||||
|
CC: ccRecipients,
|
||||||
|
Subject: subject,
|
||||||
|
Body: msgBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mailClient.UpdateDraft(messageID, draft, session.AccessToken); err != nil {
|
||||||
|
return fmt.Errorf("failed to update draft: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Draft updated successfully")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&to, "to", "t", "", "New recipient addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&cc, "cc", "c", "", "New CC addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&subject, "subject", "s", "", "New draft subject")
|
||||||
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing new draft body")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "New inline draft body")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftSendCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "send <draft-id>",
|
||||||
|
Short: "Send a draft message",
|
||||||
|
Long: `Send an existing draft message from ProtonMail.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
if err := mailClient.SendDraft(messageID); err != nil {
|
||||||
|
return fmt.Errorf("failed to send draft: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Draft sent successfully")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
415
cmd/mail.go
Normal file
415
cmd/mail.go
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/api"
|
||||||
|
"github.com/frenocorp/pop/internal/auth"
|
||||||
|
"github.com/frenocorp/pop/internal/config"
|
||||||
|
"github.com/frenocorp/pop/internal/mail"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mailCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mail",
|
||||||
|
Short: "Manage email messages",
|
||||||
|
Long: `List, read, send, delete, and manage email messages with ProtonMail.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(mailListCmd())
|
||||||
|
cmd.AddCommand(mailReadCmd())
|
||||||
|
cmd.AddCommand(mailSendCmd())
|
||||||
|
cmd.AddCommand(mailDeleteCmd())
|
||||||
|
cmd.AddCommand(mailTrashCmd())
|
||||||
|
cmd.AddCommand(mailDraftCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailListCmd() *cobra.Command {
|
||||||
|
var folder, page, pageSize, starred, readFlag string
|
||||||
|
var since int64
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List messages",
|
||||||
|
Long: `List messages from ProtonMail with pagination and folder filtering.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
folderVal := mail.FolderInbox
|
||||||
|
switch folder {
|
||||||
|
case "inbox":
|
||||||
|
folderVal = mail.FolderInbox
|
||||||
|
case "sent":
|
||||||
|
folderVal = mail.FolderSent
|
||||||
|
case "drafts":
|
||||||
|
folderVal = mail.FolderDraft
|
||||||
|
case "trash":
|
||||||
|
folderVal = mail.FolderTrash
|
||||||
|
case "spam":
|
||||||
|
folderVal = mail.FolderSpam
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageVal, err := strconv.Atoi(page)
|
||||||
|
if err != nil || pageVal < 1 {
|
||||||
|
pageVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSizeVal, err := strconv.Atoi(pageSize)
|
||||||
|
if err != nil || pageSizeVal < 1 {
|
||||||
|
pageSizeVal = 20
|
||||||
|
}
|
||||||
|
if pageSizeVal > 100 {
|
||||||
|
pageSizeVal = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var starredPtr *bool
|
||||||
|
if starred != "" {
|
||||||
|
v := starred == "true"
|
||||||
|
starredPtr = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
var readPtr *bool
|
||||||
|
if readFlag != "" {
|
||||||
|
v := readFlag == "true"
|
||||||
|
readPtr = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
req := mail.ListMessagesRequest{
|
||||||
|
Folder: folderVal,
|
||||||
|
Page: pageVal,
|
||||||
|
PageSize: pageSizeVal,
|
||||||
|
Passphrase: session.AccessToken,
|
||||||
|
Starred: starredPtr,
|
||||||
|
Read: readPtr,
|
||||||
|
Since: since,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mailClient.ListMessages(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return printMessages(result.Messages)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&folder, "folder", "inbox", "Folder to list (inbox, sent, drafts, trash, spam)")
|
||||||
|
cmd.Flags().StringVar(&page, "page", "1", "Page number")
|
||||||
|
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Messages per page (max 100)")
|
||||||
|
cmd.Flags().StringVar(&starred, "starred", "", "Filter by starred (true/false)")
|
||||||
|
cmd.Flags().StringVar(&readFlag, "read", "", "Filter by read status (true/false)")
|
||||||
|
cmd.Flags().Int64Var(&since, "since", 0, "Only messages modified since Unix timestamp")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailReadCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "read <message-id>",
|
||||||
|
Short: "Read a message",
|
||||||
|
Long: `Read and display a message, decrypting the PGP body.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
msg, err := mailClient.GetMessage(messageID, session.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return printMessageDetail(msg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailSendCmd() *cobra.Command {
|
||||||
|
var to, cc, bcc, subject, bodyFile string
|
||||||
|
var html bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "send",
|
||||||
|
Short: "Send a message",
|
||||||
|
Long: `Compose and send a message with PGP encryption.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("recipient is required (--to)")
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
return fmt.Errorf("subject is required (--subject)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := ""
|
||||||
|
if bodyFile != "" {
|
||||||
|
data, err := os.ReadFile(bodyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read body file: %w", err)
|
||||||
|
}
|
||||||
|
body = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients := parseRecipients(to)
|
||||||
|
var ccRecipients, bccRecipients []mail.Recipient
|
||||||
|
if cc != "" {
|
||||||
|
ccRecipients = parseRecipients(cc)
|
||||||
|
}
|
||||||
|
if bcc != "" {
|
||||||
|
bccRecipients = parseRecipients(bcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
req := mail.SendRequest{
|
||||||
|
To: recipients,
|
||||||
|
CC: ccRecipients,
|
||||||
|
BCC: bccRecipients,
|
||||||
|
Subject: subject,
|
||||||
|
Body: body,
|
||||||
|
HTML: html,
|
||||||
|
Passphrase: session.AccessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mailClient.Send(req); err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Message sent successfully")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&to, "to", "t", "", "Recipient addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&cc, "cc", "c", "", "CC addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&bcc, "bcc", "b", "", "BCC addresses (comma-separated)")
|
||||||
|
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Message subject")
|
||||||
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing message body")
|
||||||
|
cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML")
|
||||||
|
cmd.Flags().StringVar(&bodyFile, "body", "", "Inline message body")
|
||||||
|
_ = cmd.MarkFlagRequired("to")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailDeleteCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete <message-id>",
|
||||||
|
Short: "Permanently delete a message",
|
||||||
|
Long: `Permanently delete a message from ProtonMail. This action cannot be undone.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
if err := mailClient.PermanentlyDelete(messageID); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Message deleted permanently")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailTrashCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "trash <message-id>",
|
||||||
|
Short: "Move a message to trash",
|
||||||
|
Long: `Move a message to the trash folder.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := auth.NewSessionManager()
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := mail.NewClient(client)
|
||||||
|
|
||||||
|
if err := mailClient.MoveToTrash(messageID); err != nil {
|
||||||
|
return fmt.Errorf("failed to move to trash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Message moved to trash")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMessages(messages []mail.Message) error {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tFrom\tSubject\tDate\tStarred\tRead")
|
||||||
|
fmt.Fprintln(w, "--\t----\t-------\t----\t-------\t----")
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
id := msg.MessageID
|
||||||
|
if len(id) > 12 {
|
||||||
|
id = id[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
from := msg.Sender.DisplayName()
|
||||||
|
subject := msg.Subject
|
||||||
|
if len(subject) > 50 {
|
||||||
|
subject = subject[:47] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
date := msg.CreatedAt.Format("2006-01-02 15:04")
|
||||||
|
starred := "-"
|
||||||
|
if msg.Starred {
|
||||||
|
starred = "*"
|
||||||
|
}
|
||||||
|
read := "Y"
|
||||||
|
if !msg.Read {
|
||||||
|
read = "N"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", id, from, subject, date, starred, read)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMessageDetail(msg *mail.Message) error {
|
||||||
|
fmt.Printf("From: %s\n", msg.Sender.DisplayName())
|
||||||
|
fmt.Printf("To: %s\n", formatRecipients(msg.Recipients))
|
||||||
|
fmt.Printf("Subject: %s\n", msg.Subject)
|
||||||
|
fmt.Printf("Date: %s\n", msg.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
fmt.Printf("ID: %s\n", msg.MessageID)
|
||||||
|
fmt.Printf("Starred: %t\n", msg.Starred)
|
||||||
|
fmt.Printf("Read: %t\n", msg.Read)
|
||||||
|
fmt.Printf("Folder: %s\n", msg.Folder().Name())
|
||||||
|
|
||||||
|
if msg.Body != "" {
|
||||||
|
fmt.Printf("\n--- Body ---\n%s\n", msg.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Attachments) > 0 {
|
||||||
|
fmt.Printf("\n--- Attachments (%d) ---\n", len(msg.Attachments))
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
sizeStr := formatSize(att.Size)
|
||||||
|
fmt.Printf(" [%s] %s (%s)\n", att.AttachmentID, att.Name, sizeStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRecipients(input string) []mail.Recipient {
|
||||||
|
var recipients []mail.Recipient
|
||||||
|
for _, addr := range strings.Split(input, ",") {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mail.Recipient{Address: addr}
|
||||||
|
if strings.Contains(addr, "<") {
|
||||||
|
parts := strings.SplitN(addr, "<", 2)
|
||||||
|
r.Name = strings.TrimSpace(parts[0])
|
||||||
|
r.Address = strings.Trim(parts[1], "<>")
|
||||||
|
}
|
||||||
|
recipients = append(recipients, r)
|
||||||
|
}
|
||||||
|
return recipients
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRecipients(recipients []mail.Recipient) string {
|
||||||
|
parts := make([]string, len(recipients))
|
||||||
|
for i, r := range recipients {
|
||||||
|
parts[i] = r.DisplayName()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSize(bytes int) string {
|
||||||
|
switch {
|
||||||
|
case bytes >= 1024*1024:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024)
|
||||||
|
case bytes >= 1024:
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ func NewRootCmd() *cobra.Command {
|
|||||||
rootCmd.AddCommand(loginCmd())
|
rootCmd.AddCommand(loginCmd())
|
||||||
rootCmd.AddCommand(logoutCmd())
|
rootCmd.AddCommand(logoutCmd())
|
||||||
rootCmd.AddCommand(sessionCmd())
|
rootCmd.AddCommand(sessionCmd())
|
||||||
|
rootCmd.AddCommand(contactCmd())
|
||||||
|
rootCmd.AddCommand(attachmentCmd())
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|||||||
20
go.mod
20
go.mod
@@ -1,10 +1,20 @@
|
|||||||
module github.com/frenocorp/pop
|
module github.com/frenocorp/pop
|
||||||
|
|
||||||
go 1.21
|
go 1.23.0
|
||||||
|
|
||||||
require github.com/spf13/cobra v1.8.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/ProtonMail/gopenpgp/v2 v2.10.0
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/cobra v1.8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.2 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
golang.org/x/crypto v0.41.0 // indirect
|
||||||
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -1,10 +1,64 @@
|
|||||||
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.10.0 h1:llCzLvntC9+iH+if/na4AgKTef/Zm4vpaRrR3+JdKvo=
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.10.0/go.mod h1:dc0h9Pg3ftfN0U4pfRzujilfh61A2R52wgMkZWcWm2I=
|
||||||
|
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||||
|
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func (c *ProtonMailClient) getAuthHeader() string {
|
|||||||
return c.authHeader
|
return c.authHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ProtonMailClient) GetBaseURL() string {
|
||||||
|
return c.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) Wait() {
|
func (rl *RateLimiter) Wait() {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
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