From 7bbba9f15c8dba0026f5342f7889fcc436b5a876 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 26 Apr 2026 10:26:29 -0400 Subject: [PATCH] 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/ --- cmd/attachments.go | 124 ++++++++++ cmd/contacts.go | 174 ++++++++++++++ cmd/draft.go | 261 +++++++++++++++++++++ cmd/mail.go | 415 +++++++++++++++++++++++++++++++++ cmd/root.go | 2 + go.mod | 20 +- go.sum | 54 +++++ internal/api/client.go | 4 + internal/attachment/manager.go | 78 +++++++ internal/contact/manager.go | 206 ++++++++++++++++ internal/contact/types.go | 54 +++++ internal/mail/client.go | 342 +++++++++++++++++++++++++++ internal/mail/pgp.go | 112 +++++++++ internal/mail/types.go | 137 +++++++++++ 14 files changed, 1978 insertions(+), 5 deletions(-) create mode 100644 cmd/attachments.go create mode 100644 cmd/contacts.go create mode 100644 cmd/draft.go create mode 100644 cmd/mail.go create mode 100644 internal/attachment/manager.go create mode 100644 internal/contact/manager.go create mode 100644 internal/contact/types.go create mode 100644 internal/mail/client.go create mode 100644 internal/mail/pgp.go create mode 100644 internal/mail/types.go diff --git a/cmd/attachments.go b/cmd/attachments.go new file mode 100644 index 0000000..8cefdf0 --- /dev/null +++ b/cmd/attachments.go @@ -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 [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 ", + 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) +} diff --git a/cmd/contacts.go b/cmd/contacts.go new file mode 100644 index 0000000..6f0bbdc --- /dev/null +++ b/cmd/contacts.go @@ -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 ", + 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 ", + 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 +} diff --git a/cmd/draft.go b/cmd/draft.go new file mode 100644 index 0000000..a6fcb34 --- /dev/null +++ b/cmd/draft.go @@ -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 ", + 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 ", + 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 +} diff --git a/cmd/mail.go b/cmd/mail.go new file mode 100644 index 0000000..ca1292c --- /dev/null +++ b/cmd/mail.go @@ -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 ", + 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 ", + 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 ", + 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) + } +} diff --git a/cmd/root.go b/cmd/root.go index 0fdae2e..0b04265 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,8 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(loginCmd()) rootCmd.AddCommand(logoutCmd()) rootCmd.AddCommand(sessionCmd()) + rootCmd.AddCommand(contactCmd()) + rootCmd.AddCommand(attachmentCmd()) return rootCmd } diff --git a/go.mod b/go.mod index a205f00..04f69a4 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,20 @@ module github.com/frenocorp/pop -go 1.21 - -require github.com/spf13/cobra v1.8.0 +go 1.23.0 require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/ProtonMail/gopenpgp/v2 v2.10.0 + 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 ) diff --git a/go.sum b/go.sum index d0e8c2c..8c5683d 100644 --- a/go.sum +++ b/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/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/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/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 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/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go index 796ca1a..53ca13d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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() diff --git a/internal/attachment/manager.go b/internal/attachment/manager.go new file mode 100644 index 0000000..f0b6388 --- /dev/null +++ b/internal/attachment/manager.go @@ -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 +} diff --git a/internal/contact/manager.go b/internal/contact/manager.go new file mode 100644 index 0000000..6bceada --- /dev/null +++ b/internal/contact/manager.go @@ -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 +} diff --git a/internal/contact/types.go b/internal/contact/types.go new file mode 100644 index 0000000..c946c1c --- /dev/null +++ b/internal/contact/types.go @@ -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"` +} diff --git a/internal/mail/client.go b/internal/mail/client.go new file mode 100644 index 0000000..dacc68b --- /dev/null +++ b/internal/mail/client.go @@ -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 +} diff --git a/internal/mail/pgp.go b/internal/mail/pgp.go new file mode 100644 index 0000000..c2512d3 --- /dev/null +++ b/internal/mail/pgp.go @@ -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 +} diff --git a/internal/mail/types.go b/internal/mail/types.go new file mode 100644 index 0000000..b3d3f1a --- /dev/null +++ b/internal/mail/types.go @@ -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"` +}