- 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 <noreply@paperclip.ing>
447 lines
12 KiB
Go
447 lines
12 KiB
Go
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 <conversation-id>",
|
|
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
|
|
}
|