package cmd import ( "fmt" "net/mail" "os" "strconv" "strings" "text/tabwriter" "github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/auth" "github.com/frenocorp/pop/internal/config" internalmail "github.com/frenocorp/pop/internal/mail" "github.com/spf13/cobra" ) func checkAuthenticated() (*auth.Session, error) { sessionMgr, err := auth.NewSessionManager() if err != nil { return nil, fmt.Errorf("failed to create session manager: %w", err) } authenticated, err := sessionMgr.IsAuthenticated() if err != nil || !authenticated { return nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err) } session, err := sessionMgr.GetSession() if err != nil { return nil, fmt.Errorf("not authenticated: %w", err) } return session, nil } func checkAuthenticatedWithManager() (*auth.Session, *auth.SessionManager, error) { sessionMgr, err := auth.NewSessionManager() if err != nil { return nil, nil, fmt.Errorf("failed to create session manager: %w", err) } authenticated, err := sessionMgr.IsAuthenticated() if err != nil || !authenticated { return nil, nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err) } session, err := sessionMgr.GetSession() if err != nil { return nil, nil, fmt.Errorf("not authenticated: %w", err) } return session, sessionMgr, nil } func mailCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mail", Short: "Manage email messages", Long: `List, read, send, delete, and manage email messages with ProtonMail.`, } cmd.AddCommand(mailListCmd()) cmd.AddCommand(mailReadCmd()) cmd.AddCommand(mailSendCmd()) cmd.AddCommand(mailDeleteCmd()) cmd.AddCommand(mailTrashCmd()) cmd.AddCommand(mailDraftCmd()) cmd.AddCommand(mailSearchCmd()) return cmd } func mailListCmd() *cobra.Command { var folder, page, pageSize, starred, readFlag string var since int64 cmd := &cobra.Command{ Use: "list", Short: "List messages", Long: `List messages from ProtonMail with pagination and folder filtering.`, RunE: func(cmd *cobra.Command, args []string) error { 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) 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) } pageVal, err := strconv.Atoi(page) if err != nil || pageVal < 1 { pageVal = 1 } pageSizeVal, err := strconv.Atoi(pageSize) if err != nil || pageSizeVal < 1 { pageSizeVal = 20 } if pageSizeVal > 100 { pageSizeVal = 100 } var starredPtr *bool if starred != "" { v := starred == "true" starredPtr = &v } var readPtr *bool if readFlag != "" { v := readFlag == "true" readPtr = &v } req := internalmail.ListMessagesRequest{ Folder: folderVal, Page: pageVal, PageSize: pageSizeVal, Passphrase: session.MailPassphrase, Starred: starredPtr, Read: readPtr, Since: since, } result, err := mailClient.ListMessages(req) if err != nil { return fmt.Errorf("failed to list messages: %w", err) } return printMessages(result.Messages) }, } cmd.Flags().StringVar(&folder, "folder", "inbox", "Folder to list (inbox, sent, drafts, trash, spam)") cmd.Flags().StringVar(&page, "page", "1", "Page number") cmd.Flags().StringVar(&pageSize, "page-size", "20", "Messages per page (max 100)") cmd.Flags().StringVar(&starred, "starred", "", "Filter by starred (true/false)") cmd.Flags().StringVar(&readFlag, "read", "", "Filter by read status (true/false)") cmd.Flags().Int64Var(&since, "since", 0, "Only messages modified since Unix timestamp") return cmd } func mailReadCmd() *cobra.Command { cmd := &cobra.Command{ Use: "read ", Short: "Read a message", Long: `Read and display a message, decrypting the PGP body.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { messageID := args[0] 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) msg, err := mailClient.GetMessage(messageID, session.MailPassphrase) if err != nil { return fmt.Errorf("failed to get message: %w", err) } return printMessageDetail(msg) }, } return cmd } func mailSendCmd() *cobra.Command { var to, cc, bcc, subject, body, bodyFile string var html bool cmd := &cobra.Command{ Use: "send", Short: "Send a message", Long: `Compose and send a message with PGP encryption.`, RunE: func(cmd *cobra.Command, args []string) error { if to == "" { return fmt.Errorf("recipient is required (--to)") } if subject == "" { return fmt.Errorf("subject is required (--subject)") } var bodyContent string if body != "" { bodyContent = body } else if bodyFile != "" { data, err := os.ReadFile(bodyFile) if err != nil { return fmt.Errorf("failed to read body file: %w", err) } bodyContent = string(data) } recipients := parseRecipients(to) var ccRecipients, bccRecipients []internalmail.Recipient if cc != "" { ccRecipients = parseRecipients(cc) } if bcc != "" { bccRecipients = parseRecipients(bcc) } 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.SendRequest{ To: recipients, CC: ccRecipients, BCC: bccRecipients, Subject: subject, Body: bodyContent, HTML: html, Passphrase: session.MailPassphrase, } if err := mailClient.Send(req); err != nil { return fmt.Errorf("failed to send message: %w", err) } fmt.Println("Message sent successfully") return nil }, } cmd.Flags().StringVarP(&to, "to", "t", "", "Recipient addresses (comma-separated)") cmd.Flags().StringVarP(&cc, "cc", "c", "", "CC addresses (comma-separated)") cmd.Flags().StringVarP(&bcc, "bcc", "b", "", "BCC addresses (comma-separated)") cmd.Flags().StringVarP(&subject, "subject", "s", "", "Message subject") cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing message body") cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML") cmd.Flags().StringVar(&body, "body", "", "Inline message body") _ = cmd.MarkFlagRequired("to") return cmd } func mailDeleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Permanently delete a message", Long: `Permanently delete a message from ProtonMail. This action cannot be undone.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { messageID := args[0] 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) if err := mailClient.PermanentlyDelete(messageID); err != nil { return fmt.Errorf("failed to delete message: %w", err) } fmt.Println("Message deleted permanently") return nil }, } return cmd } func mailTrashCmd() *cobra.Command { cmd := &cobra.Command{ Use: "trash ", Short: "Move a message to trash", Long: `Move a message to the trash folder.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { messageID := args[0] 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) if err := mailClient.MoveToTrash(messageID, session.MailPassphrase); err != nil { return fmt.Errorf("failed to move to trash: %w", err) } fmt.Println("Message moved to trash") return nil }, } return cmd } func printMessages(messages []internalmail.Message) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tFrom\tSubject\tDate\tStarred\tRead") fmt.Fprintln(w, "--\t----\t-------\t----\t-------\t----") for _, msg := range messages { id := msg.MessageID if len(id) > 12 { id = id[:12] } from := msg.Sender.DisplayName() subject := msg.Subject if len(subject) > 50 { subject = subject[:47] + "..." } date := msg.CreatedAt.Format("2006-01-02 15:04") starred := "-" if msg.Starred { starred = "*" } read := "Y" if !msg.Read { read = "N" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", id, from, subject, date, starred, read) } return w.Flush() } func printMessageDetail(msg *internalmail.Message) error { fmt.Printf("From: %s\n", msg.Sender.DisplayName()) fmt.Printf("To: %s\n", formatRecipients(msg.Recipients)) fmt.Printf("Subject: %s\n", msg.Subject) fmt.Printf("Date: %s\n", msg.CreatedAt.Format("2006-01-02 15:04:05 MST")) fmt.Printf("ID: %s\n", msg.MessageID) fmt.Printf("Starred: %t\n", msg.Starred) fmt.Printf("Read: %t\n", msg.Read) fmt.Printf("Folder: %s\n", msg.Folder().Name()) if msg.Body != "" { fmt.Printf("\n--- Body ---\n%s\n", msg.Body) } if len(msg.Attachments) > 0 { fmt.Printf("\n--- Attachments (%d) ---\n", len(msg.Attachments)) for _, att := range msg.Attachments { sizeStr := formatSize(att.Size) fmt.Printf(" [%s] %s (%s)\n", att.AttachmentID, att.Name, sizeStr) } } return nil } func parseRecipients(input string) []internalmail.Recipient { var recipients []internalmail.Recipient for _, addr := range strings.Split(input, ",") { addr = strings.TrimSpace(addr) if addr == "" { continue } parsed, err := mail.ParseAddress(addr) if err != nil { fmt.Fprintf(os.Stderr, "Warning: invalid address %q: %v\n", addr, err) continue } r := internalmail.Recipient{ Name: parsed.Name, Address: parsed.Address, } recipients = append(recipients, r) } return recipients } func formatRecipients(recipients []internalmail.Recipient) string { parts := make([]string, len(recipients)) for i, r := range recipients { parts[i] = r.DisplayName() } return strings.Join(parts, ", ") } func formatSize(bytes int) string { switch { case bytes >= 1024*1024: return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024) case bytes >= 1024: return fmt.Sprintf("%.1f KB", float64(bytes)/1024) default: return fmt.Sprintf("%d B", bytes) } } func mailSearchCmd() *cobra.Command { var query, page, pageSize string cmd := &cobra.Command{ Use: "search ", Short: "Search messages", Long: `Full-text search across messages in ProtonMail.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { searchQuery := query if len(args) > 0 && args[0] != "" { searchQuery = args[0] } if searchQuery == "" { return fmt.Errorf("search query is required") } 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) pageVal, err := strconv.Atoi(page) if err != nil || pageVal < 1 { pageVal = 1 } pageSizeVal, err := strconv.Atoi(pageSize) if err != nil || pageSizeVal < 1 { pageSizeVal = 20 } if pageSizeVal > 100 { pageSizeVal = 100 } req := internalmail.SearchRequest{ Query: searchQuery, Page: pageVal, PageSize: pageSizeVal, Passphrase: session.MailPassphrase, } result, err := mailClient.SearchMessages(req) if err != nil { return fmt.Errorf("failed to search messages: %w", err) } fmt.Printf("Found %d message(s) for query: %q\n", result.Total, searchQuery) if len(result.Messages) == 0 { return nil } return printMessages(result.Messages) }, } cmd.Flags().StringVar(&query, "query", "", "Search query (or pass as positional argument)") cmd.Flags().StringVar(&page, "page", "1", "Page number") cmd.Flags().StringVar(&pageSize, "page-size", "20", "Results per page (max 100)") return cmd }