diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2de2d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +jobs: + test: + name: Go test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + run: | + fmt_out=$(gofmt -l .) + if [ -n "$fmt_out" ]; then + echo "The following files are not gofmt-formatted:" + echo "$fmt_out" + exit 1 + fi + + - name: Run vet + run: go vet ./... + + - name: Run tests + run: go test ./... diff --git a/AGENTS.md b/AGENTS.md index c61275e..e69a65a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,8 @@ Response: ```bash pm-cli mail read 123 --json +pm-cli mail read uid:456 --json +pm-cli mail read 123 --unread --json ``` Response: @@ -117,6 +119,7 @@ pm-cli mail flag 123 --star --json ```bash pm-cli mail move 123 Archive --json +pm-cli mail archive 123 --json ``` ### Delete Messages @@ -145,7 +148,7 @@ Exit codes: Messages are identified by sequence number (`seq_num`), which is the ID shown in `mail list`. Use this number for `mail read`, `mail delete`, `mail move`, and `mail flag`. -Note: Sequence numbers can change when messages are deleted. For persistent identification, use the `uid` field. +Note: Sequence numbers can change when messages are deleted. For persistent identification, use the `uid` field and pass IDs as `uid:` (for example `pm-cli mail read uid:456 --json`). ## Mailbox Names diff --git a/README.md b/README.md index 1d92820..3ac0912 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ pm-cli mail list --json # JSON output ```bash pm-cli mail read 123 # Read message #123 +pm-cli mail read uid:456 # Read by stable UID pm-cli mail read 123 -m Archive # Read from a specific mailbox pm-cli mail read 123 --json # JSON output with body pm-cli mail read 123 --headers # Include all headers +pm-cli mail read 123 --unread # Mark unread after reading pm-cli mail read 123 --html # Output HTML body pm-cli mail read 123 --attachments # List attachments pm-cli mail read 123 --raw # Raw MIME source @@ -106,12 +108,24 @@ pm-cli mail delete 123 456 789 # Batch delete pm-cli mail delete --query "from:spam@example.com" # Delete by search pm-cli mail delete 123 --permanent # Delete permanently pm-cli mail move 123 Archive # Move to folder +pm-cli mail archive 123 # Shortcut: move to Archive pm-cli mail move 123 456 -d Archive # Batch move pm-cli mail flag 123 --read # Mark as read pm-cli mail flag 123 --star # Add star pm-cli mail flag 123 456 --unread # Batch flag ``` +### Message IDs + +Use sequence numbers by default (the `ID` shown in `mail list`), or use explicit UID selectors for stable references: + +```bash +pm-cli mail read 123 # sequence number (can change over time) +pm-cli mail read uid:456 # UID selector (stable within mailbox) +``` + +JSON outputs include both `seq_num` and `uid`. `mail read` JSON and header output include `message_id` (RFC 5322 Message-ID header) when available. + ### Labels ```bash diff --git a/docs/README.md b/docs/README.md index df637ef..5ce1f77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ pm-cli │ ├── forward # Forward message │ ├── delete # Delete messages │ ├── move # Move to folder +│ ├── archive # Move to Archive │ ├── flag # Manage flags │ ├── search # Search messages │ └── download # Save attachment diff --git a/docs/ai-agents.md b/docs/ai-agents.md index 4b2537b..c801075 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -13,6 +13,8 @@ pm-cli mailbox list --json pm-cli config doctor --json ``` +Message selectors accept either sequence numbers (`123`) or stable UID selectors (`uid:456`) across read/delete/move/flag/download/thread operations. + ## Command Schema Get the full command schema as JSON: @@ -77,6 +79,12 @@ Output: } ``` +### Stable ID Guidance + +- `seq_num` is mailbox-local and can change after deletes/expunges. +- `uid` is stable within a mailbox and preferred for persistent workflows. +- Use `uid:` in command arguments when you need stability. + ### Send Email ```bash diff --git a/docs/commands.md b/docs/commands.md index 611ca4e..695e5f5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -149,6 +149,8 @@ Read a specific message. pm-cli mail read [flags] ``` +`` accepts either a sequence number (for example `123`) or `uid:` (for example `uid:456`). + **Flags:** | Flag | Description | |------|-------------| @@ -157,15 +159,18 @@ pm-cli mail read [flags] | `--headers` | Include all headers | | `--attachments` | List attachments only | | `--html` | Output HTML body instead of plain text | +| `--unread` | Mark as unread after reading (remove `\Seen`) | **Examples:** ```bash pm-cli mail read 123 +pm-cli mail read uid:456 pm-cli mail read 123 -m Archive pm-cli mail read 123 --headers pm-cli mail read 123 --raw pm-cli mail read 123 --html # View HTML content pm-cli mail read 123 --attachments +pm-cli mail read 123 --unread # Read but keep unread pm-cli mail read 123 --json ``` @@ -292,6 +297,8 @@ Delete messages. pm-cli mail delete ... [flags] ``` +`` accepts sequence numbers or `uid:`. + **Flags:** | Flag | Description | |------|-------------| @@ -312,12 +319,28 @@ Move a message to another mailbox. pm-cli mail move ``` +`` accepts sequence numbers or `uid:`. + **Examples:** ```bash pm-cli mail move 123 Archive +pm-cli mail move uid:456 Archive pm-cli mail move 123 "Projects/Active" ``` +To archive quickly: + +```bash +pm-cli mail archive ... +``` + +Examples: + +```bash +pm-cli mail archive 123 +pm-cli mail archive uid:456 +``` + ### mail flag Manage message flags. @@ -326,6 +349,8 @@ Manage message flags. pm-cli mail flag [flags] ``` +`` accepts sequence numbers or `uid:`. + **Flags:** | Flag | Description | |------|-------------| diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bcfd2f2..8482918 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,7 +5,7 @@ import ( "github.com/bscott/pm-cli/internal/output" ) -var Version = "0.2.1" +var Version = "0.2.2" type Globals struct { JSON bool `help:"Output as JSON" name:"json"` @@ -82,31 +82,32 @@ type ConfigDoctorCmd struct{} // MailCmd handles email operations type MailCmd struct { - List MailListCmd `cmd:"" help:"List messages in mailbox"` - Read MailReadCmd `cmd:"" help:"Read a specific message"` - Send MailSendCmd `cmd:"" help:"Compose and send email"` - Reply MailReplyCmd `cmd:"" help:"Reply to a message"` - Forward MailForwardCmd `cmd:"" help:"Forward a message"` - Delete MailDeleteCmd `cmd:"" help:"Delete message(s)"` - Move MailMoveCmd `cmd:"" help:"Move message to mailbox"` - Flag MailFlagCmd `cmd:"" help:"Manage message flags"` - Search MailSearchCmd `cmd:"" help:"Search messages"` - Download MailDownloadCmd `cmd:"" help:"Download attachment"` - Draft DraftCmd `cmd:"" help:"Manage drafts"` - Thread MailThreadCmd `cmd:"" help:"Show conversation thread"` - Watch MailWatchCmd `cmd:"" help:"Watch for new messages"` - Label LabelCmd `cmd:"" help:"Manage message labels"` + List MailListCmd `cmd:"" help:"List messages in mailbox"` + Read MailReadCmd `cmd:"" help:"Read a specific message"` + Send MailSendCmd `cmd:"" help:"Compose and send email"` + Reply MailReplyCmd `cmd:"" help:"Reply to a message"` + Forward MailForwardCmd `cmd:"" help:"Forward a message"` + Delete MailDeleteCmd `cmd:"" help:"Delete message(s)"` + Move MailMoveCmd `cmd:"" help:"Move message to mailbox"` + Archive MailArchiveCmd `cmd:"" help:"Move message(s) to Archive"` + Flag MailFlagCmd `cmd:"" help:"Manage message flags"` + Search MailSearchCmd `cmd:"" help:"Search messages"` + Download MailDownloadCmd `cmd:"" help:"Download attachment"` + Draft DraftCmd `cmd:"" help:"Manage drafts"` + Thread MailThreadCmd `cmd:"" help:"Show conversation thread"` + Watch MailWatchCmd `cmd:"" help:"Watch for new messages"` + Label LabelCmd `cmd:"" help:"Manage message labels"` Summarize MailSummarizeCmd `cmd:"" help:"Summarize message for AI processing"` Extract MailExtractCmd `cmd:"" help:"Extract structured data from message"` } type MailSummarizeCmd struct { - ID string `arg:"" help:"Message ID to summarize"` + ID string `arg:"" help:"Message sequence number or uid: to summarize"` Mailbox string `help:"Mailbox name" short:"m" default:"INBOX"` } type MailExtractCmd struct { - ID string `arg:"" help:"Message ID to extract data from"` + ID string `arg:"" help:"Message sequence number or uid: to extract data from"` Mailbox string `help:"Mailbox name" short:"m" default:"INBOX"` } @@ -139,7 +140,7 @@ type MailWatchCmd struct { } type MailThreadCmd struct { - ID string `arg:"" help:"Message ID to show thread for"` + ID string `arg:"" help:"Message sequence number or uid: to show thread for"` Mailbox string `help:"Mailbox to search" short:"m" default:"INBOX"` } @@ -185,12 +186,13 @@ type MailListCmd struct { } type MailReadCmd struct { - ID string `arg:"" help:"Message ID or sequence number"` + ID string `arg:"" help:"Message sequence number or uid:"` Mailbox string `help:"Mailbox name" short:"m"` Raw bool `help:"Show raw message"` Headers bool `help:"Include all headers"` Attachments bool `help:"List attachments"` HTML bool `help:"Output HTML body instead of plain text"` + Unread bool `help:"Mark as unread after reading (remove \\\\Seen)" name:"unread"` } type MailSendCmd struct { @@ -206,7 +208,7 @@ type MailSendCmd struct { } type MailReplyCmd struct { - ID string `arg:"" help:"Message ID to reply to"` + ID string `arg:"" help:"Message sequence number or uid: to reply to"` All bool `help:"Reply to all recipients" name:"all"` Body string `help:"Reply body" short:"b"` Attach []string `help:"Attachments" short:"a" type:"existingfile"` @@ -214,7 +216,7 @@ type MailReplyCmd struct { } type MailForwardCmd struct { - ID string `arg:"" help:"Message ID to forward"` + ID string `arg:"" help:"Message sequence number or uid: to forward"` To []string `help:"Recipient(s)" short:"t" required:""` Body string `help:"Additional message" short:"b"` Attach []string `help:"Additional attachments" short:"a" type:"existingfile"` @@ -222,27 +224,33 @@ type MailForwardCmd struct { } type MailDeleteCmd struct { - IDs []string `arg:"" optional:"" help:"Message ID(s) to delete"` + IDs []string `arg:"" optional:"" help:"Message sequence number(s) or uid: to delete"` Query string `help:"Delete messages matching search query (e.g., 'from:spam@example.com')"` Mailbox string `help:"Mailbox to operate on" short:"m" default:"INBOX"` Permanent bool `help:"Skip trash, delete permanently"` } type MailDownloadCmd struct { - ID string `arg:"" help:"Message ID"` + ID string `arg:"" help:"Message sequence number or uid:"` Index int `arg:"" help:"Attachment index (0-based)"` Out string `help:"Output path (default: original filename)" short:"o"` } type MailMoveCmd struct { - IDs []string `arg:"" optional:"" help:"Message ID(s) to move"` + IDs []string `arg:"" optional:"" help:"Message sequence number(s) or uid: to move"` Destination string `help:"Destination mailbox" short:"d" required:""` Query string `help:"Move messages matching search query (e.g., 'subject:newsletter')"` Mailbox string `help:"Source mailbox" short:"m" default:"INBOX"` } +type MailArchiveCmd struct { + IDs []string `arg:"" optional:"" help:"Message sequence number(s) or uid: to archive"` + Query string `help:"Archive messages matching search query (e.g., 'subject:newsletter')"` + Mailbox string `help:"Source mailbox" short:"m" default:"INBOX"` +} + type MailFlagCmd struct { - IDs []string `arg:"" optional:"" help:"Message ID(s)"` + IDs []string `arg:"" optional:"" help:"Message sequence number(s) or uid:"` Query string `help:"Flag messages matching search query (e.g., 'from:user@example.com')"` Mailbox string `help:"Mailbox to operate on" short:"m" default:"INBOX"` Read bool `help:"Mark as read" xor:"read"` diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 831f08f..c5ada4e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -121,6 +121,7 @@ func TestMailReadCmdOptions(t *testing.T) { Raw: true, Headers: true, Attachments: true, + Unread: true, } if cmd.ID != "123" { @@ -138,6 +139,9 @@ func TestMailReadCmdOptions(t *testing.T) { if !cmd.Attachments { t.Error("Attachments should be true") } + if !cmd.Unread { + t.Error("Unread should be true") + } } func TestMailSendCmdOptions(t *testing.T) { @@ -189,6 +193,20 @@ func TestMailMoveCmdOptions(t *testing.T) { } } +func TestMailArchiveCmdOptions(t *testing.T) { + cmd := MailArchiveCmd{ + IDs: []string{"123"}, + Mailbox: "INBOX", + } + + if len(cmd.IDs) != 1 || cmd.IDs[0] != "123" { + t.Errorf("IDs = %v, want [123]", cmd.IDs) + } + if cmd.Mailbox != "INBOX" { + t.Errorf("Mailbox = %q, want %q", cmd.Mailbox, "INBOX") + } +} + func TestMailFlagCmdOptions(t *testing.T) { cmd := MailFlagCmd{ IDs: []string{"123"}, diff --git a/internal/cli/help.go b/internal/cli/help.go index 447ebaa..2babf4c 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -16,12 +16,12 @@ type HelpSchema struct { } type CommandSchema struct { - Name string `json:"name"` - Description string `json:"description"` - Flags []FlagSchema `json:"flags,omitempty"` - Args []ArgSchema `json:"args,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Flags []FlagSchema `json:"flags,omitempty"` + Args []ArgSchema `json:"args,omitempty"` Subcommands []CommandSchema `json:"subcommands,omitempty"` - Examples []string `json:"examples,omitempty"` + Examples []string `json:"examples,omitempty"` } type FlagSchema struct { @@ -131,7 +131,7 @@ func extractMailCommands() CommandSchema { Name: "mail read", Description: "Read a specific message", Args: []ArgSchema{ - {Name: "id", Type: "string", Required: true, Description: "Message ID or sequence number"}, + {Name: "id", Type: "string", Required: true, Description: "Message sequence number or uid:"}, }, Flags: []FlagSchema{ {Name: "--mailbox", Short: "-m", Type: "string", Description: "Mailbox name (defaults to configured mailbox)"}, @@ -139,12 +139,15 @@ func extractMailCommands() CommandSchema { {Name: "--headers", Type: "bool", Description: "Include all headers"}, {Name: "--attachments", Type: "bool", Description: "List attachments only"}, {Name: "--html", Type: "bool", Description: "Output HTML body instead of plain text"}, + {Name: "--unread", Type: "bool", Description: "Mark as unread after reading (remove \\Seen)"}, }, Examples: []string{ "pm-cli mail read 123", + "pm-cli mail read uid:456 --json", "pm-cli mail read 123 -m Archive", "pm-cli mail read 123 --json", "pm-cli mail read 123 --raw", + "pm-cli mail read 123 --unread", }, }, { @@ -168,7 +171,7 @@ func extractMailCommands() CommandSchema { Name: "mail delete", Description: "Delete message(s)", Args: []ArgSchema{ - {Name: "ids", Type: "[]string", Required: true, Description: "Message ID(s) to delete"}, + {Name: "ids", Type: "[]string", Required: true, Description: "Message sequence number(s) or uid:"}, }, Flags: []FlagSchema{ {Name: "--permanent", Type: "bool", Description: "Skip trash, delete permanently"}, @@ -183,19 +186,32 @@ func extractMailCommands() CommandSchema { Name: "mail move", Description: "Move message to mailbox", Args: []ArgSchema{ - {Name: "id", Type: "string", Required: true, Description: "Message ID to move"}, + {Name: "id", Type: "string", Required: true, Description: "Message sequence number or uid: to move"}, {Name: "mailbox", Type: "string", Required: true, Description: "Destination mailbox"}, }, Examples: []string{ "pm-cli mail move 123 Archive", + "pm-cli mail move uid:456 Archive", "pm-cli mail move 123 'Custom Folder'", }, }, + { + Name: "mail archive", + Description: "Move message(s) to Archive", + Args: []ArgSchema{ + {Name: "ids", Type: "[]string", Required: true, Description: "Message sequence number(s) or uid:"}, + }, + Examples: []string{ + "pm-cli mail archive 123", + "pm-cli mail archive 123 124", + "pm-cli mail archive uid:456", + }, + }, { Name: "mail flag", Description: "Manage message flags", Args: []ArgSchema{ - {Name: "id", Type: "string", Required: true, Description: "Message ID"}, + {Name: "id", Type: "string", Required: true, Description: "Message sequence number or uid:"}, }, Flags: []FlagSchema{ {Name: "--read", Type: "bool", Description: "Mark as read"}, diff --git a/internal/cli/mail.go b/internal/cli/mail.go index 3c9b8bd..8b2d889 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -180,17 +180,26 @@ func (c *MailReadCmd) Run(ctx *Context) error { return err } + if c.Unread { + unreadID := fmt.Sprintf("uid:%d", msg.UID) + if err := client.SetFlags(mailbox, unreadID, false, true, false, false); err != nil { + return fmt.Errorf("failed to mark message as unread after reading: %w", err) + } + msg.Flags = normalizeFlagsForUnread(msg.Flags) + } + if ctx.Formatter.JSON { output := map[string]interface{}{ - "uid": msg.UID, - "seq_num": msg.SeqNum, - "message_id": msg.MessageID, - "from": msg.From, - "to": msg.To, - "cc": msg.CC, - "subject": msg.Subject, - "date": msg.Date, - "flags": msg.Flags, + "uid": msg.UID, + "seq_num": msg.SeqNum, + "message_id": msg.MessageID, + "from": msg.From, + "to": msg.To, + "cc": msg.CC, + "subject": msg.Subject, + "date": msg.Date, + "flags": msg.Flags, + "marked_unread": c.Unread, } // Parse body @@ -223,12 +232,14 @@ func (c *MailReadCmd) Run(ctx *Context) error { } fmt.Printf("Date: %s\n", msg.Date) fmt.Printf("Subject: %s\n", msg.Subject) + if msg.MessageID != "" { + fmt.Printf("Message-ID: %s\n", msg.MessageID) + } if c.Headers { fmt.Printf("Flags: %s\n", strings.Join(msg.Flags, ", ")) - if msg.MessageID != "" { - fmt.Printf("ID: %s\n", msg.MessageID) - } + fmt.Printf("UID: %d\n", msg.UID) + fmt.Printf("Seq: %d\n", msg.SeqNum) } fmt.Println() @@ -267,6 +278,11 @@ func (c *MailReadCmd) Run(ctx *Context) error { } } + if c.Unread { + fmt.Println() + fmt.Println("[marked as unread]") + } + return nil } @@ -629,6 +645,20 @@ func (c *MailMoveCmd) Run(ctx *Context) error { return nil } +func (c *MailArchiveCmd) Run(ctx *Context) error { + moveCmd := c.toMoveCmd() + return moveCmd.Run(ctx) +} + +func (c *MailArchiveCmd) toMoveCmd() MailMoveCmd { + return MailMoveCmd{ + IDs: c.IDs, + Destination: "Archive", + Query: c.Query, + Mailbox: c.Mailbox, + } +} + func (c *MailFlagCmd) Run(ctx *Context) error { if ctx.Config.Bridge.Email == "" { return fmt.Errorf("not configured - run 'pm-cli config init' first") @@ -1931,21 +1961,21 @@ func (c *MailSummarizeCmd) Run(ctx *Context) error { // Build structured summary summary := map[string]interface{}{ - "id": msg.SeqNum, - "uid": msg.UID, - "message_id": msg.MessageID, - "from": msg.From, - "to": msg.To, - "cc": msg.CC, - "subject": msg.Subject, - "date": msg.Date, - "date_iso": msg.DateISO, - "flags": msg.Flags, - "read": containsString(msg.Flags, "\\Seen"), - "flagged": containsString(msg.Flags, "\\Flagged"), - "body_preview": truncateBody(body, 500), - "body_length": len(body), - "has_attachments": len(msg.Attachments) > 0, + "id": msg.SeqNum, + "uid": msg.UID, + "message_id": msg.MessageID, + "from": msg.From, + "to": msg.To, + "cc": msg.CC, + "subject": msg.Subject, + "date": msg.Date, + "date_iso": msg.DateISO, + "flags": msg.Flags, + "read": containsString(msg.Flags, "\\Seen"), + "flagged": containsString(msg.Flags, "\\Flagged"), + "body_preview": truncateBody(body, 500), + "body_length": len(body), + "has_attachments": len(msg.Attachments) > 0, "attachment_count": len(msg.Attachments), } @@ -2057,6 +2087,17 @@ func containsString(slice []string, s string) bool { return false } +func normalizeFlagsForUnread(flags []string) []string { + normalized := make([]string, 0, len(flags)) + for _, flag := range flags { + if strings.EqualFold(flag, "\\Seen") { + continue + } + normalized = append(normalized, flag) + } + return normalized +} + func truncateBody(body string, maxLen int) string { if len(body) <= maxLen { return strings.TrimSpace(body) @@ -2085,12 +2126,12 @@ func extractURLs(text string) []string { func extractDates(text string) []string { // Match common date formats patterns := []string{ - `\d{4}-\d{2}-\d{2}`, // 2024-01-15 - `\d{1,2}/\d{1,2}/\d{2,4}`, // 1/15/24 or 01/15/2024 + `\d{4}-\d{2}-\d{2}`, // 2024-01-15 + `\d{1,2}/\d{1,2}/\d{2,4}`, // 1/15/24 or 01/15/2024 `(?i)(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]* \d{1,2},? \d{4}`, // January 15, 2024 `\d{1,2} (?i)(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]* \d{4}`, // 15 January 2024 } - + var dates []string for _, pattern := range patterns { re := regexp.MustCompile(pattern) @@ -2110,7 +2151,7 @@ func extractPhones(text string) []string { func extractActionItems(text string) []string { var items []string lines := strings.Split(text, "\n") - + for _, line := range lines { line = strings.TrimSpace(line) // Lines starting with -, *, •, or numbered (1., 2., etc.) diff --git a/internal/cli/mail_test.go b/internal/cli/mail_test.go index 940f7f8..c38c00c 100644 --- a/internal/cli/mail_test.go +++ b/internal/cli/mail_test.go @@ -1,6 +1,7 @@ package cli import ( + "reflect" "testing" ) @@ -259,6 +260,82 @@ func TestMailReadCmdRunWithoutConfig(t *testing.T) { } } +func TestMailArchiveCmdRunWithoutConfig(t *testing.T) { + cmd := &MailArchiveCmd{ + IDs: []string{"1"}, + } + + globals := &Globals{} + ctx, _ := NewContext(globals) + ctx.Config.Bridge.Email = "" // No email configured + + err := cmd.Run(ctx) + if err == nil { + t.Error("expected error when email not configured") + } +} + +func TestNormalizeFlagsForUnread(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "removes seen flag", + input: []string{"\\Seen", "\\Flagged"}, + expected: []string{"\\Flagged"}, + }, + { + name: "removes seen flag case insensitive", + input: []string{"\\seen", "\\Answered"}, + expected: []string{"\\Answered"}, + }, + { + name: "keeps non seen flags", + input: []string{"\\Flagged", "\\Answered"}, + expected: []string{"\\Flagged", "\\Answered"}, + }, + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeFlagsForUnread(tt.input) + if !reflect.DeepEqual(got, tt.expected) { + t.Fatalf("normalizeFlagsForUnread(%v) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestMailArchiveCmdToMoveCmd(t *testing.T) { + cmd := &MailArchiveCmd{ + IDs: []string{"1", "uid:42"}, + Query: "subject:\"invoice\"", + Mailbox: "INBOX", + } + + moveCmd := cmd.toMoveCmd() + + if moveCmd.Destination != "Archive" { + t.Fatalf("destination = %q, want %q", moveCmd.Destination, "Archive") + } + if !reflect.DeepEqual(moveCmd.IDs, cmd.IDs) { + t.Fatalf("IDs = %v, want %v", moveCmd.IDs, cmd.IDs) + } + if moveCmd.Query != cmd.Query { + t.Fatalf("query = %q, want %q", moveCmd.Query, cmd.Query) + } + if moveCmd.Mailbox != cmd.Mailbox { + t.Fatalf("mailbox = %q, want %q", moveCmd.Mailbox, cmd.Mailbox) + } +} + func TestMailSendCmdRunWithoutConfig(t *testing.T) { cmd := &MailSendCmd{ To: []string{"recipient@example.com"}, diff --git a/internal/config/config.go b/internal/config/config.go index 5f52f5c..108c615 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,11 +13,11 @@ import ( ) const ( - AppName = "pm-cli" - KeyringUser = "bridge-password" - DefaultIMAP = "127.0.0.1" + AppName = "pm-cli" + KeyringUser = "bridge-password" + DefaultIMAP = "127.0.0.1" DefaultIMAPPort = 1143 - DefaultSMTP = "127.0.0.1" + DefaultSMTP = "127.0.0.1" DefaultSMTPPort = 1025 ) diff --git a/internal/imap/client.go b/internal/imap/client.go index 1fc7634..6f81cdb 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -19,6 +19,95 @@ type Client struct { config *config.Config } +type messageSelector struct { + kind messageSelectorKind + seq uint32 + uid imap.UID +} + +type messageSelectorKind int + +const ( + selectorKindSeq messageSelectorKind = iota + selectorKindUID +) + +func parseMessageSelector(id string) (messageSelector, error) { + value := strings.TrimSpace(id) + if value == "" { + return messageSelector{}, fmt.Errorf("message ID cannot be empty") + } + + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "uid:") { + uidValue := strings.TrimSpace(value[4:]) + parsed, err := strconv.ParseUint(uidValue, 10, 32) + if err != nil || parsed == 0 { + return messageSelector{}, fmt.Errorf("invalid UID selector: %s (expected uid:)", id) + } + return messageSelector{ + kind: selectorKindUID, + uid: imap.UID(parsed), + }, nil + } + + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil || parsed == 0 { + return messageSelector{}, fmt.Errorf("invalid message ID: %s (expected sequence number or uid:)", id) + } + + return messageSelector{ + kind: selectorKindSeq, + seq: uint32(parsed), + }, nil +} + +func selectorToNumSet(selector messageSelector) imap.NumSet { + if selector.kind == selectorKindUID { + return imap.UIDSetNum(selector.uid) + } + return imap.SeqSetNum(selector.seq) +} + +func buildNumSetFromIDs(ids []string) (imap.NumSet, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("no message IDs provided") + } + + firstSelector, err := parseMessageSelector(ids[0]) + if err != nil { + return nil, err + } + + if firstSelector.kind == selectorKindUID { + set := imap.UIDSetNum(firstSelector.uid) + for _, id := range ids[1:] { + selector, err := parseMessageSelector(id) + if err != nil { + return nil, err + } + if selector.kind != selectorKindUID { + return nil, fmt.Errorf("cannot mix sequence numbers and UID selectors in one command") + } + set.AddNum(selector.uid) + } + return set, nil + } + + set := imap.SeqSetNum(firstSelector.seq) + for _, id := range ids[1:] { + selector, err := parseMessageSelector(id) + if err != nil { + return nil, err + } + if selector.kind != selectorKindSeq { + return nil, fmt.Errorf("cannot mix sequence numbers and UID selectors in one command") + } + set.AddNum(selector.seq) + } + return set, nil +} + func NewClient(cfg *config.Config) (*Client, error) { return &Client{ config: cfg, @@ -249,13 +338,12 @@ func (c *Client) GetMessage(mailbox string, id string) (*Message, error) { return nil, fmt.Errorf("mailbox is empty") } - // Parse the ID as a sequence number - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return nil, fmt.Errorf("invalid message ID: %s", id) + selector, err := parseMessageSelector(id) + if err != nil { + return nil, err } - seqSet := imap.SeqSetNum(seqNum) + numSet := selectorToNumSet(selector) fetchOptions := &imap.FetchOptions{ UID: true, @@ -265,7 +353,7 @@ func (c *Client) GetMessage(mailbox string, id string) (*Message, error) { BodySection: []*imap.FetchItemBodySection{{}}, // Fetch full body } - fetchCmd := c.client.Fetch(seqSet, fetchOptions) + fetchCmd := c.client.Fetch(numSet, fetchOptions) defer fetchCmd.Close() msg := fetchCmd.Next() @@ -330,22 +418,18 @@ func (c *Client) DeleteMessages(mailbox string, ids []string, permanent bool) er return err } - for _, id := range ids { - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return fmt.Errorf("invalid message ID: %s", id) - } - - seqSet := imap.SeqSetNum(seqNum) + numSet, err := buildNumSetFromIDs(ids) + if err != nil { + return err + } - // Add \Deleted flag - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Flags: []imap.Flag{imap.FlagDeleted}, - }, nil) - if err := storeCmd.Close(); err != nil { - return fmt.Errorf("failed to mark message %s for deletion: %w", id, err) - } + // Add \Deleted flag + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil) + if err := storeCmd.Close(); err != nil { + return fmt.Errorf("failed to mark message(s) for deletion: %w", err) } // Expunge if permanent @@ -371,18 +455,13 @@ func (c *Client) CopyMessages(mailbox string, ids []string, destMailbox string) return err } - // Build sequence set from all IDs - var seqSet imap.SeqSet - for _, id := range ids { - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return fmt.Errorf("invalid message ID: %s", id) - } - seqSet.AddNum(seqNum) + numSet, err := buildNumSetFromIDs(ids) + if err != nil { + return err } // Copy to destination (does not delete from source) - copyCmd := c.client.Copy(seqSet, destMailbox) + copyCmd := c.client.Copy(numSet, destMailbox) if _, err := copyCmd.Wait(); err != nil { return fmt.Errorf("failed to copy messages to %s: %w", destMailbox, err) } @@ -396,24 +475,19 @@ func (c *Client) MoveMessages(mailbox string, ids []string, destMailbox string) return err } - // Build sequence set from all IDs - var seqSet imap.SeqSet - for _, id := range ids { - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return fmt.Errorf("invalid message ID: %s", id) - } - seqSet.AddNum(seqNum) + numSet, err := buildNumSetFromIDs(ids) + if err != nil { + return err } // Copy to destination - copyCmd := c.client.Copy(seqSet, destMailbox) + copyCmd := c.client.Copy(numSet, destMailbox) if _, err := copyCmd.Wait(); err != nil { return fmt.Errorf("failed to copy messages to %s: %w", destMailbox, err) } // Delete from source - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, }, nil) @@ -438,18 +512,13 @@ func (c *Client) SetFlagsMultiple(mailbox string, ids []string, read, unread, st return err } - // Build sequence set from all IDs - var seqSet imap.SeqSet - for _, id := range ids { - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return fmt.Errorf("invalid message ID: %s", id) - } - seqSet.AddNum(seqNum) + numSet, err := buildNumSetFromIDs(ids) + if err != nil { + return err } if read { - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}, }, nil) @@ -459,7 +528,7 @@ func (c *Client) SetFlagsMultiple(mailbox string, ids []string, read, unread, st } if unread { - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ Op: imap.StoreFlagsDel, Flags: []imap.Flag{imap.FlagSeen}, }, nil) @@ -469,7 +538,7 @@ func (c *Client) SetFlagsMultiple(mailbox string, ids []string, read, unread, st } if star { - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagFlagged}, }, nil) @@ -479,7 +548,7 @@ func (c *Client) SetFlagsMultiple(mailbox string, ids []string, read, unread, st } if unstar { - storeCmd := c.client.Store(seqSet, &imap.StoreFlags{ + storeCmd := c.client.Store(numSet, &imap.StoreFlags{ Op: imap.StoreFlagsDel, Flags: []imap.Flag{imap.FlagFlagged}, }, nil) @@ -835,19 +904,19 @@ func (c *Client) GetAttachments(mailbox, id string) ([]Attachment, error) { return nil, err } - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return nil, fmt.Errorf("invalid message ID: %s", id) + selector, err := parseMessageSelector(id) + if err != nil { + return nil, err } - seqSet := imap.SeqSetNum(seqNum) + numSet := selectorToNumSet(selector) // Fetch BODYSTRUCTURE to get attachment info fetchOptions := &imap.FetchOptions{ BodyStructure: &imap.FetchItemBodyStructure{}, } - fetchCmd := c.client.Fetch(seqSet, fetchOptions) + fetchCmd := c.client.Fetch(numSet, fetchOptions) defer fetchCmd.Close() msg := fetchCmd.Next() @@ -940,19 +1009,19 @@ func (c *Client) DownloadAttachment(mailbox, id string, index int) ([]byte, stri return nil, "", err } - var seqNum uint32 - if _, err := fmt.Sscanf(id, "%d", &seqNum); err != nil { - return nil, "", fmt.Errorf("invalid message ID: %s", id) + selector, err := parseMessageSelector(id) + if err != nil { + return nil, "", err } - seqSet := imap.SeqSetNum(seqNum) + numSet := selectorToNumSet(selector) // First get the body structure to find the attachment part fetchOptions := &imap.FetchOptions{ BodyStructure: &imap.FetchItemBodyStructure{}, } - fetchCmd := c.client.Fetch(seqSet, fetchOptions) + fetchCmd := c.client.Fetch(numSet, fetchOptions) msg := fetchCmd.Next() if msg == nil { @@ -991,7 +1060,7 @@ func (c *Client) DownloadAttachment(mailbox, id string, index int) ([]byte, stri BodySection: []*imap.FetchItemBodySection{section}, } - fetchCmd2 := c.client.Fetch(seqSet, fetchOptions2) + fetchCmd2 := c.client.Fetch(numSet, fetchOptions2) defer fetchCmd2.Close() msg2 := fetchCmd2.Next() diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index a21394a..af0d502 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/bscott/pm-cli/internal/config" + "github.com/emersion/go-imap/v2" ) func TestNewClient(t *testing.T) { @@ -164,3 +165,91 @@ func TestClientConfig(t *testing.T) { t.Errorf("Email = %q, want %q", client.config.Bridge.Email, "user@protonmail.com") } } + +func TestParseMessageSelector(t *testing.T) { + tests := []struct { + name string + id string + wantErr bool + kind messageSelectorKind + seq uint32 + uid imap.UID + }{ + {name: "sequence number", id: "123", kind: selectorKindSeq, seq: 123}, + {name: "uid selector", id: "uid:456", kind: selectorKindUID, uid: imap.UID(456)}, + {name: "uid selector uppercase prefix", id: "UID:789", kind: selectorKindUID, uid: imap.UID(789)}, + {name: "invalid empty", id: "", wantErr: true}, + {name: "invalid zero sequence", id: "0", wantErr: true}, + {name: "invalid zero uid", id: "uid:0", wantErr: true}, + {name: "invalid string", id: "abc", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector, err := parseMessageSelector(tt.id) + if tt.wantErr { + if err == nil { + t.Fatalf("parseMessageSelector(%q) expected error", tt.id) + } + return + } + if err != nil { + t.Fatalf("parseMessageSelector(%q) error = %v", tt.id, err) + } + if selector.kind != tt.kind { + t.Fatalf("selector.kind = %v, want %v", selector.kind, tt.kind) + } + if selector.seq != tt.seq { + t.Fatalf("selector.seq = %d, want %d", selector.seq, tt.seq) + } + if selector.uid != tt.uid { + t.Fatalf("selector.uid = %d, want %d", selector.uid, tt.uid) + } + }) + } +} + +func TestBuildNumSetFromIDs(t *testing.T) { + t.Run("sequence set", func(t *testing.T) { + numSet, err := buildNumSetFromIDs([]string{"1", "2", "7"}) + if err != nil { + t.Fatalf("buildNumSetFromIDs sequence error = %v", err) + } + seqSet, ok := numSet.(imap.SeqSet) + if !ok { + t.Fatalf("expected imap.SeqSet, got %T", numSet) + } + nums, ok := seqSet.Nums() + if !ok { + t.Fatal("expected concrete sequence numbers") + } + if len(nums) != 3 { + t.Fatalf("expected 3 sequence numbers, got %d", len(nums)) + } + }) + + t.Run("uid set", func(t *testing.T) { + numSet, err := buildNumSetFromIDs([]string{"uid:10", "uid:20"}) + if err != nil { + t.Fatalf("buildNumSetFromIDs uid error = %v", err) + } + uidSet, ok := numSet.(imap.UIDSet) + if !ok { + t.Fatalf("expected imap.UIDSet, got %T", numSet) + } + nums, ok := uidSet.Nums() + if !ok { + t.Fatal("expected concrete UIDs") + } + if len(nums) != 2 { + t.Fatalf("expected 2 uids, got %d", len(nums)) + } + }) + + t.Run("mixed ids rejected", func(t *testing.T) { + _, err := buildNumSetFromIDs([]string{"1", "uid:2"}) + if err == nil { + t.Fatal("expected error when mixing sequence numbers and UID selectors") + } + }) +} diff --git a/internal/imap/types.go b/internal/imap/types.go index 0a6b90f..80f8b3b 100644 --- a/internal/imap/types.go +++ b/internal/imap/types.go @@ -25,20 +25,20 @@ type MessageSummary struct { } type Message struct { - UID uint32 `json:"uid"` - SeqNum uint32 `json:"seq_num"` - MessageID string `json:"message_id,omitempty"` - InReplyTo string `json:"in_reply_to,omitempty"` - References []string `json:"references,omitempty"` - From string `json:"from"` - To []string `json:"to"` - CC []string `json:"cc,omitempty"` - Subject string `json:"subject"` - Date string `json:"date"` - DateISO string `json:"date_iso,omitempty"` - Flags []string `json:"flags"` - Labels []string `json:"labels,omitempty"` - TextBody string `json:"text_body,omitempty"` + UID uint32 `json:"uid"` + SeqNum uint32 `json:"seq_num"` + MessageID string `json:"message_id,omitempty"` + InReplyTo string `json:"in_reply_to,omitempty"` + References []string `json:"references,omitempty"` + From string `json:"from"` + To []string `json:"to"` + CC []string `json:"cc,omitempty"` + Subject string `json:"subject"` + Date string `json:"date"` + DateISO string `json:"date_iso,omitempty"` + Flags []string `json:"flags"` + Labels []string `json:"labels,omitempty"` + TextBody string `json:"text_body,omitempty"` HTMLBody string `json:"html_body,omitempty"` RawBody []byte `json:"-"` Attachments []Attachment `json:"attachments,omitempty"`