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 }