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 <noreply@paperclip.ing>
This commit is contained in:
90
README.md
90
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 <conversation-id>
|
||||
|
||||
# Reply to a conversation
|
||||
pop thread reply <conversation-id> --body "Your reply here"
|
||||
pop thread reply <conversation-id> --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 <conversation-id> --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
|
||||
|
||||
606
cmd/draft_autosave.go
Normal file
606
cmd/draft_autosave.go
Normal file
@@ -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 <name>",
|
||||
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 <name>",
|
||||
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 <name>",
|
||||
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)
|
||||
}
|
||||
446
cmd/export.go
Normal file
446
cmd/export.go
Normal file
@@ -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 <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
|
||||
}
|
||||
17
cmd/root.go
17
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())
|
||||
|
||||
Reference in New Issue
Block a user