- Add SessionRefresher interface for token refresh abstraction - Update ProtonMailClient to auto-refresh on 401 responses - Add DoWithContext method for context-aware HTTP requests - Update SessionManager with RefreshTokenWithContext method - Update LoginWithCredentials and LoginInteractive to accept context - Add checkAuthenticatedWithManager helper for commands needing session manager - All API methods now support proper cancellation via context.Context Files changed: - internal/api/client.go - Auto-refresh on 401, context support - internal/auth/session.go - Context-aware refresh and login methods - internal/auth/interface.go - SessionRefresher interface - cmd/mail.go, cmd/draft.go, cmd/folders.go - Updated to use new helpers - cmd/auth.go - Context support for login commands Co-Authored-By: Paperclip <noreply@paperclip.ing>
524 lines
14 KiB
Go
524 lines
14 KiB
Go
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 <message-id>",
|
|
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 <message-id>",
|
|
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 <message-id>",
|
|
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 <query>",
|
|
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
|
|
}
|