Files
pop/cmd/mail.go
Michael Freno 7bbba9f15c FRE-683: Add contacts & attachments management
- Implemented contact CRUD operations (list, add, edit, delete)
- Implemented attachment management (list, upload, download)
- Created internal/contact/manager.go for contact persistence
- Created internal/attachment/manager.go for attachment storage
- Added CLI commands in cmd/contacts.go and cmd/attachments.go
- Integrated contact and attachment commands into root CLI

Files:
- internal/contact/types.go - Contact data models
- internal/contact/manager.go - Contact CRUD operations
- internal/attachment/manager.go - Attachment file operations
- cmd/contacts.go - Contact CLI commands
- cmd/attachments.go - Attachment CLI commands

Contacts stored in ~/.config/pop/contacts.json
Attachments stored in ~/.config/pop/attachments/
2026-04-26 10:26:29 -04:00

416 lines
10 KiB
Go

package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config"
"github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
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())
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)
}
sessionMgr := auth.NewSessionManager()
session, err := sessionMgr.GetSession()
if err != nil {
return fmt.Errorf("not authenticated: %w", err)
}
client := api.NewProtonMailClient(cfg)
client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client)
folderVal := mail.FolderInbox
switch folder {
case "inbox":
folderVal = mail.FolderInbox
case "sent":
folderVal = mail.FolderSent
case "drafts":
folderVal = mail.FolderDraft
case "trash":
folderVal = mail.FolderTrash
case "spam":
folderVal = mail.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 := mail.ListMessagesRequest{
Folder: folderVal,
Page: pageVal,
PageSize: pageSizeVal,
Passphrase: session.AccessToken,
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)
}
sessionMgr := auth.NewSessionManager()
session, err := sessionMgr.GetSession()
if err != nil {
return fmt.Errorf("not authenticated: %w", err)
}
client := api.NewProtonMailClient(cfg)
client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client)
msg, err := mailClient.GetMessage(messageID, session.AccessToken)
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, 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)")
}
body := ""
if bodyFile != "" {
data, err := os.ReadFile(bodyFile)
if err != nil {
return fmt.Errorf("failed to read body file: %w", err)
}
body = string(data)
}
recipients := parseRecipients(to)
var ccRecipients, bccRecipients []mail.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)
}
sessionMgr := auth.NewSessionManager()
session, err := sessionMgr.GetSession()
if err != nil {
return fmt.Errorf("not authenticated: %w", err)
}
client := api.NewProtonMailClient(cfg)
client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client)
req := mail.SendRequest{
To: recipients,
CC: ccRecipients,
BCC: bccRecipients,
Subject: subject,
Body: body,
HTML: html,
Passphrase: session.AccessToken,
}
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(&bodyFile, "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)
}
sessionMgr := auth.NewSessionManager()
session, err := sessionMgr.GetSession()
if err != nil {
return fmt.Errorf("not authenticated: %w", err)
}
client := api.NewProtonMailClient(cfg)
client.SetAuthHeader(session.AccessToken)
mailClient := mail.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)
}
sessionMgr := auth.NewSessionManager()
session, err := sessionMgr.GetSession()
if err != nil {
return fmt.Errorf("not authenticated: %w", err)
}
client := api.NewProtonMailClient(cfg)
client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client)
if err := mailClient.MoveToTrash(messageID); 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 []mail.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 *mail.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) []mail.Recipient {
var recipients []mail.Recipient
for _, addr := range strings.Split(input, ",") {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
r := mail.Recipient{Address: addr}
if strings.Contains(addr, "<") {
parts := strings.SplitN(addr, "<", 2)
r.Name = strings.TrimSpace(parts[0])
r.Address = strings.Trim(parts[1], "<>")
}
recipients = append(recipients, r)
}
return recipients
}
func formatRecipients(recipients []mail.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)
}
}