- 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>
607 lines
15 KiB
Go
607 lines
15 KiB
Go
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)
|
|
}
|