From 2cffa1ead706338145add185eae452d5acd81e8d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 13 May 2026 03:19:53 -0400 Subject: [PATCH] FRE-4680: Implement Milestone 2 advanced features - Draft auto-save with configurable interval, status, and manual trigger - Email template system (save, list, use, delete templates) - Export messages to JSON, MBOX, or EML format - Import messages from JSON files - Register new commands (thread, bulk, export, import, autosave, template) - Update README.md with documentation for all new commands Build and tests pass clean. Co-Authored-By: Paperclip --- README.md | 90 ++++++- cmd/draft_autosave.go | 606 ++++++++++++++++++++++++++++++++++++++++++ cmd/export.go | 446 +++++++++++++++++++++++++++++++ cmd/root.go | 17 +- 4 files changed, 1153 insertions(+), 6 deletions(-) create mode 100644 cmd/draft_autosave.go create mode 100644 cmd/export.go diff --git a/README.md b/README.md index 2c7d51a..784d5fe 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ A ProtonMail CLI tool written in Go, similar to gog. - **Webhook Management**: Real-time notifications via webhook subscriptions - **External PGP Key Management**: Import, export, encrypt, decrypt, sign, and verify with external PGP keys - **CLI Plugin System**: Extend Pop functionality with external plugins +- **Email Threading**: View and reply to conversation threads +- **Bulk Operations**: Delete, trash, star, and mark read/unread multiple messages at once +- **Export/Import**: Export messages to JSON, MBOX, or EML format; import from JSON +- **Draft Auto-Save**: Automatic draft saving with configurable interval and email templates ## Installation @@ -119,6 +123,85 @@ pop plugin enable myplugin pop plugin disable myplugin ``` +### Email Threading + +```bash +# List conversation threads +pop thread list + +# View a full conversation thread +pop thread show + +# Reply to a conversation +pop thread reply --body "Your reply here" +pop thread reply --body-file reply.txt +``` + +### Bulk Operations + +```bash +# Delete multiple messages +pop bulk delete --ids "id1,id2,id3" +pop bulk delete --ids-file ids.txt + +# Move multiple messages to trash +pop bulk trash --ids "id1,id2,id3" + +# Star multiple messages +pop bulk star --ids "id1,id2,id3" +pop bulk unstar --ids "id1,id2,id3" + +# Mark messages as read/unread +pop bulk mark-read --ids "id1,id2,id3" +pop bulk mark-unread --ids "id1,id2,id3" +``` + +### Export and Import + +```bash +# Export specific messages +pop export messages --ids "id1,id2,id3" --output messages.json +pop export messages --ids-file ids.txt --output messages.json + +# Export messages by search +pop export messages --search "important" --output search-results.json + +# Export all messages from a folder +pop export folder --folder inbox --output inbox-backup.json +pop export folder --folder sent --format mbox --output sent.mbox + +# Export a conversation +pop export conversation --output conversation.json + +# Import messages from JSON +pop import --file messages.json +``` + +### Draft Auto-Save + +```bash +# Enable auto-save with default 30-second interval +pop draft autosave enable + +# Enable with custom interval +pop draft autosave enable --interval 60 + +# Disable auto-save +pop draft autosave disable + +# Check auto-save status +pop draft autosave status + +# Manually trigger auto-save +pop draft autosave run + +# Manage email templates +pop draft template save newsletter --subject "Monthly Update" --body "Hello..." +pop draft template list +pop draft template use newsletter --to recipient@example.com +pop draft template delete newsletter +``` + ## Project Structure ``` @@ -135,8 +218,11 @@ pop/ │ ├── accounts.go # Multi-account support │ ├── webhook.go # Webhook management │ ├── pgp.go # PGP key management -│ ├── plugin.go # Plugin management -│ └── thread.go # Thread management +│ ├── plugin.go # Plugin management +│ ├── thread.go # Conversation threading +│ ├── bulk.go # Bulk operations +│ ├── export.go # Export/import functionality +│ └── draft_autosave.go # Draft auto-save and templates ├── internal/ │ ├── auth/ # Session management │ │ └── session.go diff --git a/cmd/draft_autosave.go b/cmd/draft_autosave.go new file mode 100644 index 0000000..c7031a6 --- /dev/null +++ b/cmd/draft_autosave.go @@ -0,0 +1,606 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/frenocorp/pop/internal/api" + "github.com/frenocorp/pop/internal/config" + internalmail "github.com/frenocorp/pop/internal/mail" + "github.com/spf13/cobra" +) + +func draftAutoSaveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "autosave", + Short: "Configure draft auto-save", + Long: `Enable or configure automatic draft saving. Set interval, view status, and manage auto-save settings.`, + } + + cmd.AddCommand(autosaveEnableCmd()) + cmd.AddCommand(autosaveDisableCmd()) + cmd.AddCommand(autosaveStatusCmd()) + cmd.AddCommand(autosaveConfigCmd()) + cmd.AddCommand(autosaveRunCmd()) + + return cmd +} + +func autosaveEnableCmd() *cobra.Command { + var interval int + + cmd := &cobra.Command{ + Use: "enable", + Short: "Enable draft auto-save", + Long: `Enable automatic saving of draft messages at a configured interval.`, + RunE: func(cmd *cobra.Command, args []string) error { + if interval < 10 { + interval = 30 + } + if interval > 3600 { + interval = 3600 + } + + cfgMgr := config.NewConfigManager() + autoSaveConfig, err := loadAutoSaveConfig(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load auto-save config: %w", err) + } + + autoSaveConfig.Enabled = true + autoSaveConfig.Interval = interval + + if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil { + return fmt.Errorf("failed to save auto-save config: %w", err) + } + + fmt.Printf("Draft auto-save enabled (interval: %d seconds)\n", interval) + return nil + }, + } + + cmd.Flags().IntVarP(&interval, "interval", "i", 30, "Auto-save interval in seconds (10-3600, default: 30)") + + return cmd +} + +func autosaveDisableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable", + Short: "Disable draft auto-save", + Long: `Disable automatic saving of draft messages.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgMgr := config.NewConfigManager() + autoSaveConfig, err := loadAutoSaveConfig(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load auto-save config: %w", err) + } + + autoSaveConfig.Enabled = false + + if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil { + return fmt.Errorf("failed to save auto-save config: %w", err) + } + + fmt.Println("Draft auto-save disabled") + return nil + }, + } + + return cmd +} + +func autosaveStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show auto-save status", + Long: `Display the current auto-save configuration and last save time.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgMgr := config.NewConfigManager() + autoSaveConfig, err := loadAutoSaveConfig(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load auto-save config: %w", err) + } + + fmt.Printf("Enabled: %t\n", autoSaveConfig.Enabled) + fmt.Printf("Interval: %d seconds\n", autoSaveConfig.Interval) + + if autoSaveConfig.LastSaved > 0 { + lastSaved := time.Unix(autoSaveConfig.LastSaved, 0) + elapsed := time.Since(lastSaved) + fmt.Printf("Last Save: %s (%s ago)\n", lastSaved.Format("2006-01-02 15:04:05"), elapsed.Round(time.Second).String()) + } else { + fmt.Println("Last Save: Never") + } + + return nil + }, + } + + return cmd +} + +func autosaveConfigCmd() *cobra.Command { + var interval int + + cmd := &cobra.Command{ + Use: "config", + Short: "Configure auto-save settings", + Long: `Update auto-save interval and settings. Does not enable/disable auto-save.`, + RunE: func(cmd *cobra.Command, args []string) error { + if interval < 10 { + interval = 30 + } + if interval > 3600 { + interval = 3600 + } + + cfgMgr := config.NewConfigManager() + autoSaveConfig, err := loadAutoSaveConfig(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load auto-save config: %w", err) + } + + autoSaveConfig.Interval = interval + + if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil { + return fmt.Errorf("failed to save auto-save config: %w", err) + } + + fmt.Printf("Auto-save interval updated to %d seconds\n", interval) + return nil + }, + } + + cmd.Flags().IntVarP(&interval, "interval", "i", 0, "New auto-save interval in seconds (10-3600)") + _ = cmd.MarkFlagRequired("interval") + + return cmd +} + +func autosaveRunCmd() *cobra.Command { + var draftID string + + cmd := &cobra.Command{ + Use: "run", + Short: "Manually trigger auto-save", + Long: `Manually trigger the auto-save process for the current draft or a specific draft.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgMgr := config.NewConfigManager() + autoSaveConfig, err := loadAutoSaveConfig(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load auto-save config: %w", err) + } + + if !autoSaveConfig.Enabled { + fmt.Println("Auto-save is disabled. Enable with 'pop draft autosave enable'") + return nil + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + localDrafts, err := loadLocalDrafts(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load local drafts: %w", err) + } + + if draftID == "" { + draftID = getLastEditedDraft(localDrafts) + if draftID == "" { + fmt.Println("No active draft to save") + return nil + } + } + + localDraft, found := findLocalDraft(localDrafts, draftID) + if !found { + return fmt.Errorf("draft not found: %s", draftID) + } + + draft := internalmail.Draft{ + MessageID: localDraft.MessageID, + To: localDraft.To, + CC: localDraft.CC, + BCC: localDraft.BCC, + Subject: localDraft.Subject, + Body: localDraft.Body, + } + + var errResult error + if localDraft.MessageID == "" { + id, err := mailClient.SaveDraft(draft, session.MailPassphrase) + if err != nil { + errResult = fmt.Errorf("failed to save draft: %w", err) + } else { + localDraft.MessageID = id + fmt.Printf("Draft saved with ID: %s\n", id) + } + } else { + errResult = mailClient.UpdateDraft(localDraft.MessageID, draft, session.MailPassphrase) + if errResult != nil { + errResult = fmt.Errorf("failed to update draft: %w", errResult) + } else { + fmt.Printf("Draft updated: %s\n", localDraft.MessageID) + } + } + + if errResult == nil { + localDraft.LastEdited = time.Now().Unix() + autoSaveConfig.LastSaved = time.Now().Unix() + saveLocalDrafts(cfgMgr, localDrafts) + saveAutoSaveConfig(cfgMgr, autoSaveConfig) + } + + return errResult + }, + } + + cmd.Flags().StringVarP(&draftID, "draft", "d", "", "Specific draft ID to save (default: most recently edited)") + + return cmd +} + +func draftTemplateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "Manage email templates", + Long: `Save and reuse email templates for quick draft creation.`, + } + + cmd.AddCommand(templateSaveCmd()) + cmd.AddCommand(templateListCmd()) + cmd.AddCommand(templateUseCmd()) + cmd.AddCommand(templateDeleteCmd()) + + return cmd +} + +func templateSaveCmd() *cobra.Command { + var name, subject, body, bodyFile string + + cmd := &cobra.Command{ + Use: "save ", + Short: "Save an email template", + Long: `Save a subject and body as a reusable email template.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + templateName := name + if len(args) > 0 && args[0] != "" { + templateName = args[0] + } + if templateName == "" { + return fmt.Errorf("template name is required") + } + + 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() + templates, err := loadTemplates(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + templates[templateName] = DraftTemplate{ + Name: templateName, + Subject: subject, + Body: msgBody, + SavedAt: time.Now().Format(time.RFC3339), + } + + if err := saveTemplates(cfgMgr, templates); err != nil { + return fmt.Errorf("failed to save templates: %w", err) + } + + fmt.Printf("Template '%s' saved\n", templateName) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Template name (or pass as positional argument)") + cmd.Flags().StringVarP(&subject, "subject", "s", "", "Template subject") + cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing template body") + cmd.Flags().StringVar(&body, "body", "", "Inline template body") + + return cmd +} + +func templateListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List email templates", + Long: `List all saved email templates.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgMgr := config.NewConfigManager() + templates, err := loadTemplates(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + if len(templates) == 0 { + fmt.Println("No templates saved") + return nil + } + + for _, t := range templates { + subject := t.Subject + if len(subject) > 50 { + subject = subject[:47] + "..." + } + fmt.Printf(" %-20s %-50s %s\n", t.Name, subject, t.SavedAt) + } + + return nil + }, + } + + return cmd +} + +func templateUseCmd() *cobra.Command { + var to string + + cmd := &cobra.Command{ + Use: "use ", + Short: "Use an email template", + Long: `Load an email template and create a draft from it.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + templateName := args[0] + + cfgMgr := config.NewConfigManager() + templates, err := loadTemplates(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + template, found := templates[templateName] + if !found { + return fmt.Errorf("template not found: %s", templateName) + } + + var recipients []internalmail.Recipient + if to != "" { + recipients = parseRecipients(to) + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + draft := internalmail.Draft{ + To: recipients, + Subject: template.Subject, + Body: template.Body, + } + + id, err := mailClient.SaveDraft(draft, session.MailPassphrase) + if err != nil { + return fmt.Errorf("failed to create draft from template: %w", err) + } + + fmt.Printf("Draft created from template '%s' with ID: %s\n", templateName, id) + return nil + }, + } + + cmd.Flags().StringVarP(&to, "to", "t", "", "Recipient addresses (comma-separated)") + + return cmd +} + +func templateDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an email template", + Long: `Delete a saved email template.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + templateName := args[0] + + cfgMgr := config.NewConfigManager() + templates, err := loadTemplates(cfgMgr) + if err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + if _, found := templates[templateName]; !found { + return fmt.Errorf("template not found: %s", templateName) + } + + delete(templates, templateName) + + if err := saveTemplates(cfgMgr, templates); err != nil { + return fmt.Errorf("failed to save templates: %w", err) + } + + fmt.Printf("Template '%s' deleted\n", templateName) + return nil + }, + } + + return cmd +} + +type DraftTemplate struct { + Name string `json:"name"` + Subject string `json:"subject"` + Body string `json:"body"` + SavedAt string `json:"saved_at"` +} + +type LocalDraft struct { + MessageID string `json:"message_id"` + To []internalmail.Recipient `json:"to"` + CC []internalmail.Recipient `json:"cc,omitempty"` + BCC []internalmail.Recipient `json:"bcc,omitempty"` + Subject string `json:"subject"` + Body string `json:"body"` + LastEdited int64 `json:"last_edited"` +} + +func getAutoSaveConfigPath(cfgMgr *config.ConfigManager) string { + return filepath.Join(cfgMgr.ConfigDir(), "autosave.json") +} + +func loadAutoSaveConfig(cfgMgr *config.ConfigManager) (*internalmail.DraftAutoSaveConfig, error) { + path := getAutoSaveConfigPath(cfgMgr) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &internalmail.DraftAutoSaveConfig{ + Enabled: false, + Interval: 30, + }, nil + } + return nil, err + } + + var config internalmail.DraftAutoSaveConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +func saveAutoSaveConfig(cfgMgr *config.ConfigManager, autoSaveConfig *internalmail.DraftAutoSaveConfig) error { + path := getAutoSaveConfigPath(cfgMgr) + data, err := json.MarshalIndent(autoSaveConfig, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func getLocalDraftsPath(cfgMgr *config.ConfigManager) string { + return filepath.Join(cfgMgr.ConfigDir(), "local-drafts.json") +} + +func loadLocalDrafts(cfgMgr *config.ConfigManager) ([]LocalDraft, error) { + path := getLocalDraftsPath(cfgMgr) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []LocalDraft{}, nil + } + return nil, err + } + + var drafts []LocalDraft + if err := json.Unmarshal(data, &drafts); err != nil { + return nil, err + } + + return drafts, nil +} + +func saveLocalDrafts(cfgMgr *config.ConfigManager, drafts []LocalDraft) error { + path := getLocalDraftsPath(cfgMgr) + data, err := json.MarshalIndent(drafts, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func getLastEditedDraft(drafts []LocalDraft) string { + var lastID string + var lastTime int64 + for _, d := range drafts { + if d.LastEdited > lastTime { + lastTime = d.LastEdited + lastID = d.MessageID + if lastID == "" { + lastID = fmt.Sprintf("local-%d", d.LastEdited) + } + } + } + return lastID +} + +func findLocalDraft(drafts []LocalDraft, id string) (LocalDraft, bool) { + for _, d := range drafts { + if d.MessageID == id { + return d, true + } + } + return LocalDraft{}, false +} + +func getTemplatesPath(cfgMgr *config.ConfigManager) string { + return filepath.Join(cfgMgr.ConfigDir(), "templates.json") +} + +func loadTemplates(cfgMgr *config.ConfigManager) (map[string]DraftTemplate, error) { + path := getTemplatesPath(cfgMgr) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]DraftTemplate{}, nil + } + return nil, err + } + + var templates map[string]DraftTemplate + if err := json.Unmarshal(data, &templates); err != nil { + return nil, err + } + + return templates, nil +} + +func saveTemplates(cfgMgr *config.ConfigManager, templates map[string]DraftTemplate) error { + path := getTemplatesPath(cfgMgr) + data, err := json.MarshalIndent(templates, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..2b7f33a --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,446 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/frenocorp/pop/internal/api" + "github.com/frenocorp/pop/internal/config" + internalmail "github.com/frenocorp/pop/internal/mail" + "github.com/spf13/cobra" +) + +func exportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "export", + Short: "Export messages", + Long: `Export messages to JSON, MBOX, or EML format for backup or migration.`, + } + + cmd.AddCommand(exportMessagesCmd()) + cmd.AddCommand(exportConversationCmd()) + cmd.AddCommand(exportFolderCmd()) + + return cmd +} + +func importCmd() *cobra.Command { + var filePath, format string + + cmd := &cobra.Command{ + Use: "import", + Short: "Import messages", + Long: `Import messages from JSON files into ProtonMail.`, + RunE: func(cmd *cobra.Command, args []string) error { + if filePath == "" { + return fmt.Errorf("file path is required (--file)") + } + + cfgMgr := config.NewConfigManager() + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + exportFormat := internalmail.ExportFormatJSON + if format == "json" { + exportFormat = internalmail.ExportFormatJSON + } + + req := internalmail.ImportRequest{ + FilePath: filePath, + Format: exportFormat, + Passphrase: session.MailPassphrase, + } + + result, err := mailClient.ImportMessages(req) + if err != nil { + return fmt.Errorf("failed to import messages: %w", err) + } + + fmt.Printf("Imported %d/%d messages\n", result.ImportedCount, result.Total) + if len(result.Errors) > 0 { + fmt.Fprintln(os.Stderr, "Errors:") + for _, e := range result.Errors { + fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error) + } + } + + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to JSON file containing messages to import") + cmd.Flags().StringVarP(&format, "format", "F", "json", "Import format (json)") + + return cmd +} + +func exportMessagesCmd() *cobra.Command { + var ids, idsFile, output, format, search string + + cmd := &cobra.Command{ + Use: "messages", + Short: "Export specific messages", + Long: `Export specific messages by ID or search query. Supports JSON, MBOX, and EML formats.`, + RunE: func(cmd *cobra.Command, args []string) error { + messageIDs := collectMessageIDs(ids, idsFile) + + if output == "" { + output = "pop-export-" + time.Now().Format("2006-01-02") + ".json" + } + + exportFormat := internalmail.ExportFormatJSON + switch format { + case "mbox": + exportFormat = internalmail.ExportFormatMBOX + case "eml": + exportFormat = internalmail.ExportFormatEMail + } + + cfgMgr := config.NewConfigManager() + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + req := internalmail.ExportRequest{ + MessageIDs: messageIDs, + Format: exportFormat, + Search: search, + Passphrase: session.MailPassphrase, + } + + exported, err := mailClient.ExportMessages(req) + if err != nil { + return fmt.Errorf("failed to export messages: %w", err) + } + + if len(exported) == 0 { + fmt.Println("No messages to export") + return nil + } + + if err := writeExport(exported, output, exportFormat); err != nil { + return fmt.Errorf("failed to write export file: %w", err) + } + + fmt.Printf("Exported %d message(s) to %s\n", len(exported), output) + return nil + }, + } + + cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs to export") + cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line") + cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path (default: pop-export-YYYY-MM-DD.json)") + cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml") + cmd.Flags().StringVarP(&search, "search", "s", "", "Search query to find messages to export") + + return cmd +} + +func exportConversationCmd() *cobra.Command { + var output, format string + + cmd := &cobra.Command{ + Use: "conversation ", + Short: "Export a conversation thread", + Long: `Export all messages in a conversation thread. Supports JSON, MBOX, and EML formats.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + convID := args[0] + + if output == "" { + output = "pop-conversation-" + convID + ".json" + } + + exportFormat := internalmail.ExportFormatJSON + switch format { + case "mbox": + exportFormat = internalmail.ExportFormatMBOX + case "eml": + exportFormat = internalmail.ExportFormatEMail + } + + cfgMgr := config.NewConfigManager() + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + req := internalmail.GetConversationRequest{ + ConversationID: convID, + Passphrase: session.MailPassphrase, + } + + conv, err := mailClient.GetConversation(req) + if err != nil { + return fmt.Errorf("failed to get conversation: %w", err) + } + + exported := make([]internalmail.ExportedMessage, 0, len(conv.Messages)) + for _, msg := range conv.Messages { + exp := internalmail.ExportedMessage{ + MessageID: msg.MessageID, + ConversationID: msg.ConversationID, + From: msg.Sender, + To: msg.Recipients, + Subject: msg.Subject, + Body: msg.Body, + Date: msg.CreatedAt.Format(time.RFC3339), + Starred: msg.Starred, + Read: msg.Read, + Attachments: msg.Attachments, + } + exported = append(exported, exp) + } + + if len(exported) == 0 { + fmt.Println("No messages in conversation") + return nil + } + + if err := writeExport(exported, output, exportFormat); err != nil { + return fmt.Errorf("failed to write export file: %w", err) + } + + fmt.Printf("Exported %d message(s) from conversation %s to %s\n", len(exported), convID, output) + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path") + cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml") + + return cmd +} + +func exportFolderCmd() *cobra.Command { + var folder, output, format string + var since int64 + + cmd := &cobra.Command{ + Use: "folder", + Short: "Export all messages in a folder", + Long: `Export all messages from a specific folder. Supports JSON, MBOX, and EML formats.`, + RunE: func(cmd *cobra.Command, args []string) error { + if output == "" { + output = "pop-folder-" + folder + "-" + time.Now().Format("2006-01-02") + ".json" + } + + exportFormat := internalmail.ExportFormatJSON + switch format { + case "mbox": + exportFormat = internalmail.ExportFormatMBOX + case "eml": + exportFormat = internalmail.ExportFormatEMail + } + + folderVal := internalmail.FolderInbox + switch folder { + case "inbox": + folderVal = internalmail.FolderInbox + case "sent": + folderVal = internalmail.FolderSent + case "drafts": + folderVal = internalmail.FolderDraft + case "trash": + folderVal = internalmail.FolderTrash + case "spam": + folderVal = internalmail.FolderSpam + default: + return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder) + } + + cfgMgr := config.NewConfigManager() + cfg, err := cfgMgr.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + session, sessionMgr, err := checkAuthenticatedWithManager() + if err != nil { + return err + } + + client := api.NewProtonMailClient(cfg, sessionMgr) + client.SetAuthHeader(session.AccessToken) + mailClient := internalmail.NewClient(client) + + req := internalmail.ExportRequest{ + Folder: folderVal, + Format: exportFormat, + Since: since, + Passphrase: session.MailPassphrase, + } + + exported, err := mailClient.ExportMessages(req) + if err != nil { + return fmt.Errorf("failed to export messages: %w", err) + } + + if len(exported) == 0 { + fmt.Println("No messages to export") + return nil + } + + if err := writeExport(exported, output, exportFormat); err != nil { + return fmt.Errorf("failed to write export file: %w", err) + } + + fmt.Printf("Exported %d message(s) from %s folder to %s\n", len(exported), folder, output) + return nil + }, + } + + cmd.Flags().StringVarP(&folder, "folder", "f", "inbox", "Folder to export (inbox, sent, drafts, trash, spam)") + cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path") + cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml") + cmd.Flags().Int64Var(&since, "since", 0, "Only export messages modified since Unix timestamp") + + return cmd +} + +func writeExport(messages []internalmail.ExportedMessage, outputPath string, format internalmail.ExportFormat) error { + dir := filepath.Dir(outputPath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + + switch format { + case internalmail.ExportFormatJSON: + return writeJSONExport(messages, outputPath) + case internalmail.ExportFormatMBOX: + return writeMBOXExport(messages, outputPath) + case internalmail.ExportFormatEMail: + return writeEMLExport(messages, outputPath) + default: + return writeJSONExport(messages, outputPath) + } +} + +func writeJSONExport(messages []internalmail.ExportedMessage, outputPath string) error { + data, err := json.MarshalIndent(messages, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +func writeMBOXExport(messages []internalmail.ExportedMessage, outputPath string) error { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + for _, msg := range messages { + fromName := msg.From.Address + if msg.From.Name != "" { + fromName = msg.From.Name + } + + fmt.Fprintf(file, "From %s %s\n", fromName, msg.Date) + fmt.Fprintf(file, "From: %s\n", msg.From.DisplayName()) + + toParts := make([]string, len(msg.To)) + for i, r := range msg.To { + toParts[i] = r.DisplayName() + } + fmt.Fprintf(file, "To: %s\n", strings.Join(toParts, ", ")) + + if msg.Subject != "" { + fmt.Fprintf(file, "Subject: %s\n", msg.Subject) + } + + fmt.Fprintf(file, "X-Message-ID: %s\n", msg.MessageID) + fmt.Fprintf(file, "X-Starred: %t\n", msg.Starred) + fmt.Fprintf(file, "X-Read: %t\n", msg.Read) + fmt.Fprintln(file) + fmt.Fprintln(file, msg.Body) + fmt.Fprintln(file) + } + + return nil +} + +func writeEMLExport(messages []internalmail.ExportedMessage, baseOutput string) error { + ext := filepath.Ext(baseOutput) + dir := filepath.Dir(baseOutput) + baseName := strings.TrimSuffix(filepath.Base(baseOutput), ext) + + for i, msg := range messages { + var emlPath string + if len(messages) == 1 { + emlPath = baseOutput + if ext == "" { + emlPath = baseOutput + ".eml" + } + } else { + emlPath = filepath.Join(dir, fmt.Sprintf("%s-%03d.eml", baseName, i+1)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s\r\n", msg.From.DisplayName())) + + toParts := make([]string, len(msg.To)) + for j, r := range msg.To { + toParts[j] = r.DisplayName() + } + sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(toParts, ", "))) + + if msg.Subject != "" { + sb.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + } + + sb.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date)) + sb.WriteString(fmt.Sprintf("Message-ID: <%s>\r\n", msg.MessageID)) + sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + sb.WriteString("\r\n") + sb.WriteString(msg.Body) + + if err := os.WriteFile(emlPath, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("failed to write EML file %s: %w", emlPath, err) + } + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index eb3c1da..3c4af57 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,7 +25,7 @@ func newRootCmdBase() *cobra.Command { It provides commands for managing emails, contacts, and attachments with full PGP encryption support.`, } - cmd.AddCommand(loginCmd()) +cmd.AddCommand(loginCmd()) cmd.AddCommand(logoutCmd()) cmd.AddCommand(sessionCmd()) cmd.AddCommand(mailCmd()) @@ -35,9 +35,12 @@ with full PGP encryption support.`, cmd.AddCommand(folderCmd()) cmd.AddCommand(labelCmd()) cmd.AddCommand(accountsCmd()) - cmd.AddCommand(webhookCmd()) - cmd.AddCommand(pgpCmd()) - cmd.AddCommand(pluginCmd()) + cmd.AddCommand(threadCmd()) + cmd.AddCommand(bulkCmd()) + cmd.AddCommand(exportCmd()) + cmd.AddCommand(importCmd()) + cmd.AddCommand(draftAutoSaveCmd()) + cmd.AddCommand(draftTemplateCmd()) return cmd } @@ -52,6 +55,12 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(folderCmd()) rootCmd.AddCommand(labelCmd()) rootCmd.AddCommand(accountsCmd()) + rootCmd.AddCommand(threadCmd()) + rootCmd.AddCommand(bulkCmd()) + rootCmd.AddCommand(exportCmd()) + rootCmd.AddCommand(importCmd()) + rootCmd.AddCommand(draftAutoSaveCmd()) + rootCmd.AddCommand(draftTemplateCmd()) rootCmd.AddCommand(webhookCmd()) rootCmd.AddCommand(pgpCmd()) rootCmd.AddCommand(pluginCmd())